Every D-pad step on the TV home ends with a spotlight swap, and on a low-end box that frame ran over budget: 12 % of UI-thread CPU during navigation was paragraph layout, most of it shaping strings a second time to measure them before the render tree shaped them for real. - FittingTitleText no longer lays the title out to learn whether it fits: Text wraps and ellipsizes at maxLines, so the box can only overflow vertically, and one cached one-glyph line height settles that. Only a box shorter than maxLines lines still searches, now bracketed by the proportional shrink of the base layout and stopped at a 0.25 px tolerance instead of a fixed 12 bisections. - FittedMetadataLine and inlineRatingBadgeWidth measure through a small bounded cache keyed by text, style, scaler and direction, so separators, type labels, certifications, runtimes and years measure once per session. - MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their regex tokenisation per key; every card build and step re-asked them. Cold navigation on the Android 14 box (30 steps over three hubs): build 312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90 7.5-7.9 ms -> 6.8-7.2 ms.
139 lines
4.5 KiB
Dart
139 lines
4.5 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/text_measure_cache.dart';
|
|
|
|
class FittingTitleText extends StatelessWidget {
|
|
final String text;
|
|
final TextStyle? style;
|
|
final int maxLines;
|
|
final TextOverflow overflow;
|
|
final TextAlign? textAlign;
|
|
final AlignmentGeometry alignment;
|
|
final double minFontSize;
|
|
|
|
const FittingTitleText(
|
|
this.text, {
|
|
super.key,
|
|
this.style,
|
|
this.maxLines = 2,
|
|
this.overflow = TextOverflow.ellipsis,
|
|
this.textAlign,
|
|
this.alignment = Alignment.centerLeft,
|
|
this.minFontSize = 1,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final baseStyle = style ?? DefaultTextStyle.of(context).style;
|
|
if (PlatformDetector.isAutomotive()) {
|
|
return Align(
|
|
alignment: alignment,
|
|
child: Text(text, style: baseStyle, maxLines: maxLines, overflow: overflow, textAlign: textAlign),
|
|
);
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
var fittedStyle = baseStyle;
|
|
if (constraints.hasBoundedWidth &&
|
|
constraints.hasBoundedHeight &&
|
|
constraints.maxWidth > 0 &&
|
|
constraints.maxHeight > 0) {
|
|
fittedStyle = baseStyle.copyWith(
|
|
fontSize: _fitFontSize(
|
|
text: text,
|
|
style: baseStyle,
|
|
maxWidth: constraints.maxWidth,
|
|
maxHeight: constraints.maxHeight,
|
|
textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr,
|
|
textScaler: MediaQuery.textScalerOf(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Align(
|
|
alignment: alignment,
|
|
child: Text(text, style: fittedStyle, maxLines: maxLines, overflow: overflow, textAlign: textAlign),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Sizes that differ by less than this are indistinguishable on screen, so
|
|
/// the bisection stops there instead of running a fixed iteration count.
|
|
static const double _fontSizeTolerance = 0.25;
|
|
|
|
/// The largest font size, down to [minFontSize], at which [text] fits the
|
|
/// box.
|
|
///
|
|
/// The box can only overflow vertically: [Text] wraps and ellipsizes at
|
|
/// [maxLines], so a title fits whenever [maxLines] lines of the base style
|
|
/// do. That is settled from one cached one-glyph line height, without
|
|
/// shaping the title — which the render tree is about to do anyway and
|
|
/// which is the single most expensive thing a TV spotlight swap does on a
|
|
/// low-end box. Only a box shorter than [maxLines] lines needs the search,
|
|
/// and it is bracketed: layout height scales with the font size at a fixed
|
|
/// line count, so the proportional shrink of the base layout is a fitting
|
|
/// lower bound and the bisection only refines between it and the base size.
|
|
double _fitFontSize({
|
|
required String text,
|
|
required TextStyle style,
|
|
required double maxWidth,
|
|
required double maxHeight,
|
|
required TextDirection textDirection,
|
|
required TextScaler textScaler,
|
|
}) {
|
|
final baseFontSize = style.fontSize ?? 14;
|
|
if (baseFontSize <= minFontSize) return baseFontSize;
|
|
|
|
final lineHeight = cachedSingleLineTextSize(
|
|
'X',
|
|
style: style,
|
|
textScaler: textScaler,
|
|
textDirection: textDirection,
|
|
).height;
|
|
if (maxLines * lineHeight <= maxHeight + 0.1) return baseFontSize;
|
|
|
|
final painter = TextPainter(
|
|
maxLines: maxLines,
|
|
ellipsis: overflow == TextOverflow.ellipsis ? '\u2026' : null,
|
|
textDirection: textDirection,
|
|
textScaler: textScaler,
|
|
textAlign: textAlign ?? TextAlign.start,
|
|
);
|
|
try {
|
|
bool fits(double fontSize) {
|
|
painter
|
|
..text = TextSpan(
|
|
text: text,
|
|
style: style.copyWith(fontSize: fontSize),
|
|
)
|
|
..layout(maxWidth: maxWidth);
|
|
return painter.height <= maxHeight + 0.1 && painter.width <= maxWidth + 0.1;
|
|
}
|
|
|
|
if (fits(baseFontSize)) return baseFontSize;
|
|
final shrink = math.min(maxHeight / painter.height, maxWidth / painter.width);
|
|
|
|
var low = math.max(minFontSize, baseFontSize * shrink);
|
|
if (low <= minFontSize || !fits(low)) {
|
|
if (!fits(minFontSize)) return minFontSize;
|
|
low = minFontSize;
|
|
}
|
|
var high = baseFontSize;
|
|
while (high - low > _fontSizeTolerance) {
|
|
final mid = (low + high) / 2;
|
|
if (fits(mid)) {
|
|
low = mid;
|
|
} else {
|
|
high = mid;
|
|
}
|
|
}
|
|
return low;
|
|
} finally {
|
|
painter.dispose();
|
|
}
|
|
}
|
|
}
|