feat(text): introduce AdaptiveText and EllipsisText components for improved text handling

This commit is contained in:
ZhuJHua
2025-02-04 02:33:47 +08:00
parent 4436162bd7
commit 7acbc7854d
10 changed files with 271 additions and 237 deletions

View File

@@ -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<Marquee> 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

View File

@@ -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<double>('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<double>('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,
),
);
}
}

View File

@@ -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,
),
],
);
}

View File

@@ -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,
// ),
// ),
// ),
// ),
],
),
),

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
),
],

View File

@@ -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,
);
}),
],

View File

@@ -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,
),
],
),