From 7acbc7854d3a4f9dc8663af99ff46308d25ad150 Mon Sep 17 00:00:00 2001 From: ZhuJHua <1624109111@qq.com> Date: Tue, 4 Feb 2025 02:33:47 +0800 Subject: [PATCH] feat(text): introduce AdaptiveText and EllipsisText components for improved text handling --- lib/components/base/marquee.dart | 8 +- lib/components/base/text.dart | 364 +++++++++++------- lib/components/dashboard/dashboard_view.dart | 28 +- .../diary_card/grid_diary_card_view.dart | 31 +- .../diary_card/list_diary_card_view.dart | 7 +- lib/components/tile/setting_tile.dart | 30 +- lib/pages/assistant/assistant_view.dart | 5 +- lib/pages/font/font_view.dart | 14 +- lib/pages/home/diary/diary_view.dart | 14 +- lib/pages/home/setting/setting_view.dart | 7 +- 10 files changed, 271 insertions(+), 237 deletions(-) diff --git a/lib/components/base/marquee.dart b/lib/components/base/marquee.dart index 3ccd6b6..2eecfa9 100644 --- a/lib/components/base/marquee.dart +++ b/lib/components/base/marquee.dart @@ -44,7 +44,7 @@ class Marquee extends StatefulWidget { super.key, required this.text, this.style, - this.textScaleFactor, + this.textScaler, this.textDirection = TextDirection.ltr, this.scrollAxis = Axis.horizontal, this.crossAxisAlignment = CrossAxisAlignment.center, @@ -97,7 +97,7 @@ class Marquee extends StatefulWidget { decelerationCurve = IntegralCurve(decelerationCurve); final String text; final TextStyle? style; - final double? textScaleFactor; + final TextScaler? textScaler; final TextDirection textDirection; final Axis scrollAxis; final CrossAxisAlignment crossAxisAlignment; @@ -279,9 +279,7 @@ class _MarqueeState extends State with SingleTickerProviderStateMixin { ? Text( widget.text, style: widget.style, - textScaler: widget.textScaleFactor != null - ? TextScaler.linear(widget.textScaleFactor!) - : null, + textScaler: widget.textScaler, ) : SizedBox( width: widget.scrollAxis == Axis.horizontal diff --git a/lib/components/base/text.dart b/lib/components/base/text.dart index 8355990..2deb6df 100644 --- a/lib/components/base/text.dart +++ b/lib/components/base/text.dart @@ -1,157 +1,239 @@ import 'package:flutter/material.dart'; import 'package:moodiary/components/base/marquee.dart'; -import 'package:moodiary/presentation/pref.dart'; -Widget buildAdaptiveText({ - required String text, - TextStyle? textStyle, - required BuildContext context, - double? maxWidth, - bool? isTileTitle, - bool? isTileSubtitle, - bool? isPrimaryTitle, - bool? isTitle, -}) { - late final colorScheme = Theme.of(context).colorScheme; - late final textTheme = Theme.of(context).textTheme; - if (isTileTitle == true) { - textStyle = textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface); - } - if (isTileSubtitle == true) { - textStyle = - textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant); - } - if (isTitle == true) { - textStyle = textTheme.titleLarge; - } - if (isPrimaryTitle == true) { - textStyle = textTheme.titleLarge?.copyWith( - color: colorScheme.primary, - ); - } - return LayoutBuilder( - builder: (context, constraints) { - final textPainter = TextPainter( - text: TextSpan(text: text, style: textStyle), - textDirection: TextDirection.ltr, - textScaler: - TextScaler.linear(PrefUtil.getValue('fontScale')!)) - ..layout(); - return textPainter.width > (maxWidth ?? constraints.maxWidth) - ? SizedBox( - height: textPainter.height, - width: maxWidth ?? constraints.maxWidth, - child: Marquee( - text: text, - velocity: 20, - blankSpace: 20, - textScaleFactor: PrefUtil.getValue('fontScale')!, - pauseAfterRound: const Duration(seconds: 1), - accelerationDuration: const Duration(seconds: 1), - accelerationCurve: Curves.linear, - decelerationDuration: const Duration(milliseconds: 300), - decelerationCurve: Curves.easeOut, - style: textStyle, - ), - ) - : Text( - text, - style: textStyle, - overflow: TextOverflow.ellipsis, - ); - }, - ); -} +class AdaptiveText extends StatelessWidget { + final String text; + final TextStyle? style; + final double? maxWidth; + final bool? isTileTitle; + final bool? isTileSubtitle; + final bool? isPrimaryTitle; + final bool? isTitle; -Widget getEllipsisText({ - required String text, - required int maxLine, - TextStyle? textStyle, - required TextScaler textScaler, - String ellipsis = '...', -}) { - return LayoutBuilder(builder: (context, constraints) { - if (text.isEmpty) { - return const SizedBox.shrink(); + const AdaptiveText( + this.text, { + super.key, + this.style, + this.maxWidth, + this.isTileTitle, + this.isTileSubtitle, + this.isPrimaryTitle, + this.isTitle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final textScaler = MediaQuery.textScalerOf(context); + var textStyle = style; + if (isTileTitle == true) { + textStyle = textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface); } - - double calculateTextWidth(String text) { - final span = TextSpan(text: text.fixAutoLines(), style: textStyle); - final tp = TextPainter( - text: span, - maxLines: 1, - textDirection: TextDirection.ltr, - textScaler: textScaler, - )..layout(); - return tp.width; + if (isTileSubtitle == true) { + textStyle = + textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant); } - - TextPainter createTextPainter(String displayText) { - return TextPainter( - text: TextSpan(text: displayText.fixAutoLines(), style: textStyle), - maxLines: maxLine, - textDirection: TextDirection.ltr, - textScaler: textScaler, - )..layout(maxWidth: constraints.maxWidth); + if (isTitle == true) { + textStyle = textTheme.titleLarge; } - - if (!createTextPainter(text).didExceedMaxLines) { - return Text( - text.fixAutoLines(), - maxLines: maxLine, - overflow: TextOverflow.ellipsis, - style: textStyle, - textScaler: textScaler, + if (isPrimaryTitle == true) { + textStyle = textTheme.titleLarge?.copyWith( + color: colorScheme.primary, ); } - - int leftIndex = 0; - int rightIndex = text.length; - double leftWidth = 0; - double rightWidth = 0; - - String truncatedText = text; - - int lastValidLeftIndex = 0; - int lastValidRightIndex = text.length; - - while (leftIndex < rightIndex) { - final nextLeftWidth = calculateTextWidth(text[leftIndex]) + leftWidth; - final nextRightWidth = - calculateTextWidth(text[rightIndex - 1]) + rightWidth; - final currentText = - '${text.substring(0, leftIndex)}$ellipsis${text.substring(rightIndex)}'; - if (createTextPainter(currentText).didExceedMaxLines) { - break; - } else { - lastValidLeftIndex = leftIndex; - lastValidRightIndex = rightIndex; - if (leftWidth <= rightWidth) { - leftWidth = nextLeftWidth; - leftIndex++; - } else { - rightWidth = nextRightWidth; - rightIndex--; - } - } - } - - final leftText = text.substring(0, lastValidLeftIndex); - final rightText = text.substring(lastValidRightIndex); - - truncatedText = '$leftText$ellipsis$rightText'; - - return Text( - truncatedText.fixAutoLines(), - maxLines: maxLine, - style: textStyle, - textScaler: textScaler, + return LayoutBuilder( + builder: (context, constraints) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: textStyle), + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + return textPainter.width > (maxWidth ?? constraints.maxWidth) + ? SizedBox( + height: textPainter.height, + width: maxWidth ?? constraints.maxWidth, + child: Marquee( + text: text, + velocity: 20, + blankSpace: 20, + textScaler: textScaler, + pauseAfterRound: const Duration(seconds: 1), + accelerationDuration: const Duration(seconds: 1), + accelerationCurve: Curves.linear, + decelerationDuration: const Duration(milliseconds: 300), + decelerationCurve: Curves.easeOut, + style: textStyle, + ), + ) + : Text( + text, + style: textStyle, + overflow: TextOverflow.ellipsis, + ); + }, ); - }); + } } -extension FixAutoLines on String { +class EllipsisText extends StatelessWidget { + final String text; + final TextStyle? style; + final String ellipsis; + final int? maxLines; + + const EllipsisText( + this.text, { + super.key, + this.style, + this.ellipsis = '...', + this.maxLines, + }); + + double _calculateTextWidth( + String text, + TextScaler textScaler, + ) { + final span = TextSpan(text: text.fixAutoLines(), style: style); + final tp = TextPainter( + text: span, + maxLines: 1, + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + return tp.width; + } + + TextPainter _createTextPainter( + String displayText, + double maxWidth, + TextScaler textScaler, + ) { + return TextPainter( + text: TextSpan(text: displayText.fixAutoLines(), style: style), + maxLines: maxLines, + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(maxWidth: maxWidth); + } + + @override + Widget build(BuildContext context) { + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder(builder: (context, constraints) { + if (text.isEmpty) { + return const SizedBox.shrink(); + } + if (!_createTextPainter( + text, + constraints.maxWidth, + textScaler, + ).didExceedMaxLines) { + return Text( + text.fixAutoLines(), + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: style, + textScaler: textScaler, + ); + } + + int leftIndex = 0; + int rightIndex = text.characters.length; + double leftWidth = 0; + double rightWidth = 0; + + String truncatedText = text; + + int lastValidLeftIndex = 0; + int lastValidRightIndex = text.characters.length; + + while (leftIndex < rightIndex) { + final nextLeftWidth = + _calculateTextWidth(text.getCharacters(leftIndex), textScaler) + + leftWidth; + final nextRightWidth = _calculateTextWidth( + text.getCharacters(rightIndex - 1), textScaler) + + rightWidth; + final currentText = + '${text.runeSubstring(0, leftIndex)}$ellipsis${text.runeSubstring(rightIndex)}'; + if (_createTextPainter( + currentText, + constraints.maxWidth, + textScaler, + ).didExceedMaxLines) { + break; + } else { + lastValidLeftIndex = leftIndex; + lastValidRightIndex = rightIndex; + if (leftWidth <= rightWidth) { + leftWidth = nextLeftWidth; + leftIndex++; + } else { + rightWidth = nextRightWidth; + rightIndex--; + } + } + } + + final leftText = text.runeSubstring(0, lastValidLeftIndex); + final rightText = text.runeSubstring(lastValidRightIndex); + + truncatedText = '$leftText$ellipsis$rightText'; + + return Text( + truncatedText.fixAutoLines(), + maxLines: maxLines, + style: style, + textScaler: textScaler, + ); + }); + } +} + +extension StringExt on String { String fixAutoLines() { return Characters(this).join('\u{200B}'); } + + String runeSubstring(int start, [int? end]) { + return String.fromCharCodes(runes.toList().sublist(start, end)); + } + + String getCharacters(int index) { + return Characters(this).elementAt(index); + } +} + +class AnimatedText extends StatelessWidget { + const AnimatedText( + this.text, { + super.key, + required this.style, + required this.isFetching, + this.placeholder = '...', + }); + + final String placeholder; + final String text; + final TextStyle? style; + final bool isFetching; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isFetching + ? Text( + placeholder, + key: const ValueKey('empty'), + style: style, + ) + : Text( + text, + key: const ValueKey('text'), + style: style, + ), + ); + } } diff --git a/lib/components/dashboard/dashboard_view.dart b/lib/components/dashboard/dashboard_view.dart index 9fd8fa5..914b11e 100644 --- a/lib/components/dashboard/dashboard_view.dart +++ b/lib/components/dashboard/dashboard_view.dart @@ -18,27 +18,17 @@ class DashboardComponent extends StatelessWidget { return Column( spacing: 8.0, children: [ - buildAdaptiveText( - text: title, - textStyle: + AdaptiveText( + title, + style: textStyle.labelMedium?.copyWith(color: colorScheme.onSurface), - context: context, ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: count.isEmpty - ? Text( - '...', - key: const ValueKey('count_empty'), - style: textStyle.titleMedium - ?.copyWith(color: colorScheme.secondary), - ) - : Text( - count, - key: const ValueKey('count'), - style: textStyle.titleMedium - ?.copyWith(color: colorScheme.secondary), - )), + AnimatedText( + count, + style: + textStyle.titleMedium?.copyWith(color: colorScheme.secondary), + isFetching: count.isEmpty, + ), ], ); } diff --git a/lib/components/diary_card/grid_diary_card_view.dart b/lib/components/diary_card/grid_diary_card_view.dart index e405dea..6cbda47 100644 --- a/lib/components/diary_card/grid_diary_card_view.dart +++ b/lib/components/diary_card/grid_diary_card_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:moodiary/common/models/isar/diary.dart'; import 'package:moodiary/common/values/border.dart'; +import 'package:moodiary/components/base/text.dart'; import 'package:moodiary/components/diary_card/basic_card_logic.dart'; import 'package:moodiary/utils/file_util.dart'; @@ -56,17 +57,15 @@ class GirdDiaryCardComponent extends StatelessWidget with BasicCardLogic { spacing: 4.0, children: [ if (diary.title.isNotEmpty) - Text( + EllipsisText( diary.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, + maxLines: 1, style: textStyle.titleMedium ?.copyWith(color: colorScheme.onSurface), ), if (diary.contentText.isNotEmpty) - Text( + EllipsisText( diary.contentText.trim(), - overflow: TextOverflow.ellipsis, maxLines: 4, style: textStyle.bodyMedium ?.copyWith(color: colorScheme.onSurface), @@ -83,28 +82,6 @@ class GirdDiaryCardComponent extends StatelessWidget with BasicCardLogic { ) ], ), - // Positioned( - // top: 4, - // right: 4, - // child: Container( - // decoration: ShapeDecoration( - // shape: const CircleBorder(), - // color: colorScheme.surfaceContainerLow), - // width: 12, - // height: 12, - // child: Center( - // child: Container( - // decoration: ShapeDecoration( - // shape: const CircleBorder(), - // color: Color.lerp(AppColor.emoColorList.first, - // AppColor.emoColorList.last, diary.mood), - // ), - // width: 8, - // height: 8, - // ), - // ), - // ), - // ), ], ), ), diff --git a/lib/components/diary_card/list_diary_card_view.dart b/lib/components/diary_card/list_diary_card_view.dart index 1be4481..9f694bf 100644 --- a/lib/components/diary_card/list_diary_card_view.dart +++ b/lib/components/diary_card/list_diary_card_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:moodiary/common/models/isar/diary.dart'; import 'package:moodiary/common/values/border.dart'; +import 'package:moodiary/components/base/text.dart'; import 'package:moodiary/components/diary_card/basic_card_logic.dart'; import 'package:moodiary/utils/file_util.dart'; @@ -61,18 +62,16 @@ class ListDiaryCardComponent extends StatelessWidget with BasicCardLogic { spacing: 4.0, children: [ if (diary.title.isNotEmpty) ...[ - Text( + EllipsisText( diary.title, - overflow: TextOverflow.ellipsis, style: textStyle.titleMedium ?.copyWith(color: colorScheme.onSurface), maxLines: 1, ) ], Expanded( - child: Text( + child: EllipsisText( diary.contentText.trim(), - overflow: TextOverflow.ellipsis, maxLines: diary.title.isNotEmpty ? 3 : 4, style: textStyle.bodyMedium ?.copyWith(color: colorScheme.onSurface), diff --git a/lib/components/tile/setting_tile.dart b/lib/components/tile/setting_tile.dart index 65196a2..d900aa6 100644 --- a/lib/components/tile/setting_tile.dart +++ b/lib/components/tile/setting_tile.dart @@ -20,16 +20,14 @@ class AdaptiveTitleTile extends StatelessWidget { ); return ListTile( title: (title is String) - ? buildAdaptiveText( - text: title, - context: context, + ? AdaptiveText( + title, isPrimaryTitle: true, ) : title, subtitle: (subtitle is String) - ? buildAdaptiveText( - text: subtitle!, - context: context, + ? AdaptiveText( + subtitle!, isTileSubtitle: true, ) : subtitle, @@ -86,9 +84,8 @@ class AdaptiveListTile extends StatelessWidget { } return ListTile( title: (realTitle is String) - ? buildAdaptiveText( - text: realTitle, - context: context, + ? AdaptiveText( + realTitle, isTileTitle: true, ) : realTitle, @@ -100,9 +97,8 @@ class AdaptiveListTile extends StatelessWidget { ), contentPadding: contentPadding, subtitle: (realSubtitle is String) - ? buildAdaptiveText( - text: realSubtitle, - context: context, + ? AdaptiveText( + realSubtitle, isTileSubtitle: true, ) : realSubtitle, @@ -162,9 +158,8 @@ class AdaptiveSwitchListTile extends StatelessWidget { } return SwitchListTile( title: (realTitle is String) - ? buildAdaptiveText( - text: realTitle, - context: context, + ? AdaptiveText( + realTitle, isTileTitle: true, ) : realTitle, @@ -179,9 +174,8 @@ class AdaptiveSwitchListTile extends StatelessWidget { ), ), subtitle: (realSubtitle is String) - ? buildAdaptiveText( - text: realSubtitle, - context: context, + ? AdaptiveText( + realSubtitle, isTileSubtitle: true, ) : realSubtitle, diff --git a/lib/pages/assistant/assistant_view.dart b/lib/pages/assistant/assistant_view.dart index e52bd8c..579b059 100644 --- a/lib/pages/assistant/assistant_view.dart +++ b/lib/pages/assistant/assistant_view.dart @@ -156,9 +156,8 @@ class AssistantPage extends StatelessWidget { controller: logic.scrollController, slivers: [ SliverAppBar( - title: buildAdaptiveText( - text: l10n.settingFunctionAIAssistant, - context: context, + title: AdaptiveText( + l10n.settingFunctionAIAssistant, isTitle: true, ), pinned: true, diff --git a/lib/pages/font/font_view.dart b/lib/pages/font/font_view.dart index e06558b..92dd653 100644 --- a/lib/pages/font/font_view.dart +++ b/lib/pages/font/font_view.dart @@ -46,10 +46,9 @@ class FontPage extends StatelessWidget { ), ), ), - buildAdaptiveText( - text: l10n.fontStyleSystem, - textStyle: textStyle, - context: context, + AdaptiveText( + l10n.fontStyleSystem, + style: textStyle, ), ], ); @@ -96,10 +95,9 @@ class FontPage extends StatelessWidget { ), ), ), - buildAdaptiveText( - text: fontName, - textStyle: textStyle, - context: context, + AdaptiveText( + fontName, + style: textStyle, maxWidth: 64, ), ], diff --git a/lib/pages/home/diary/diary_view.dart b/lib/pages/home/diary/diary_view.dart index 632ed70..6216996 100644 --- a/lib/pages/home/diary/diary_view.dart +++ b/lib/pages/home/diary/diary_view.dart @@ -158,22 +158,20 @@ class DiaryPage extends StatelessWidget { HapticFeedback.selectionClick(); }, child: Obx(() { - return buildAdaptiveText( - text: state.customTitleName.value.isNotEmpty + return AdaptiveText( + state.customTitleName.value.isNotEmpty ? state.customTitleName.value : l10n.appName, - context: context, - textStyle: textStyle.titleLarge?.copyWith( + style: textStyle.titleLarge?.copyWith( color: colorScheme.onSurface, )); }), ), Obx(() { - return buildAdaptiveText( - text: state.hitokoto.value, - textStyle: textStyle.labelSmall + return AdaptiveText( + state.hitokoto.value, + style: textStyle.labelSmall ?.copyWith(color: colorScheme.onSurfaceVariant), - context: context, ); }), ], diff --git a/lib/pages/home/setting/setting_view.dart b/lib/pages/home/setting/setting_view.dart index bfa42be..b45a61b 100644 --- a/lib/pages/home/setting/setting_view.dart +++ b/lib/pages/home/setting/setting_view.dart @@ -53,11 +53,10 @@ class SettingPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ icon, - buildAdaptiveText( - text: text, - textStyle: textStyle.labelSmall + AdaptiveText( + text, + style: textStyle.labelSmall ?.copyWith(color: colorScheme.secondary), - context: context, ), ], ),