Files
ReelShort/lib/pages/player/widgets/expandable_synopsis.dart
T
2026-10-31 09:41:19 +08:00

173 lines
5.2 KiB
Dart

// Author: fruit.com.cn
// 职责:推荐流与播放页共用的简介样式、固定切换入口与高度过渡。
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _synopsisFont = 15;
const double _synopsisLineHeight = 1.4;
const int _collapsedSynopsisLines = 2;
const double _synopsisToggleWidthFraction = 0.55;
const double _synopsisToggleGap = 6;
const _synopsisMotion = Duration(milliseconds: 240);
const _synopsisStyle = TextStyle(
fontSize: _synopsisFont,
fontWeight: FontWeight.w400,
height: _synopsisLineHeight,
);
const _synopsisToggleStyle = TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w400,
height: _synopsisLineHeight,
);
/// 两行折叠的简介,展开与收起入口在右下共用同一占位。
class ExpandableSynopsis extends StatefulWidget {
/// [keyPrefix] 区分页内标识,[expandedFooter] 仅在展开时展示。
const ExpandableSynopsis({
super.key,
required this.synopsis,
this.keyPrefix = 'home-feed',
this.expandedFooter,
this.textColor = AppColors.homeFeedVideoCopy,
});
/// 按当前语言获取的简介正文。
final String synopsis;
/// 当前页面的组件标识前缀。
final String keyPrefix;
/// 展开后附加的声明等内容。
final Widget? expandedFooter;
/// 简介正文颜色;默认使用视频信息区统一的半透明浅灰白。
final Color textColor;
@override
State<ExpandableSynopsis> createState() => _ExpandableSynopsisState();
}
class _ExpandableSynopsisState extends State<ExpandableSynopsis> {
bool _expanded = false;
@override
void didUpdateWidget(covariant ExpandableSynopsis oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.synopsis != widget.synopsis) _expanded = false;
}
@override
Widget build(BuildContext context) {
if (widget.synopsis.trim().isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
return LayoutBuilder(
builder: (context, constraints) {
if (!_needsToggle(constraints.maxWidth)) return _body();
return Row(
key: _key('layout'),
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: _animatedBody(),
),
),
const SizedBox(width: _synopsisToggleGap),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * _synopsisToggleWidthFraction,
),
child: IntrinsicWidth(child: _toggleButton(l10n)),
),
],
);
},
);
}
bool _needsToggle(double maxWidth) {
final painter = _painter(widget.synopsis, _bodyStyle);
painter.layout(maxWidth: maxWidth);
final overflows = painter.didExceedMaxLines;
painter.dispose();
return overflows;
}
TextPainter _painter(String text, TextStyle style) => TextPainter(
text: TextSpan(
text: text,
style: DefaultTextStyle.of(context).style.merge(style),
),
maxLines: _collapsedSynopsisLines,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
locale: Localizations.localeOf(context),
textHeightBehavior: DefaultTextStyle.of(context).textHeightBehavior,
);
Widget _animatedBody() {
if (MediaQuery.disableAnimationsOf(context)) return _body();
return AnimatedSize(
key: _key('size'),
duration: _synopsisMotion,
curve: Curves.easeInOutCubic,
alignment: Alignment.topLeft,
child: _body(),
);
}
Key _key(String suffix) => Key('${widget.keyPrefix}-synopsis-$suffix');
TextStyle get _bodyStyle => _synopsisStyle.copyWith(color: widget.textColor);
Widget _body() {
final style = _bodyStyle;
final text = Text.rich(
TextSpan(text: widget.synopsis, style: style),
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
style: style,
maxLines: _expanded ? null : _collapsedSynopsisLines,
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
);
if (!_expanded || widget.expandedFooter == null) return text;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [text, widget.expandedFooter!],
);
}
Widget _toggleButton(AppLocalizations l10n) => Semantics(
button: true,
expanded: _expanded,
child: GestureDetector(
key: _key('more'),
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: IndexedStack(
index: _expanded ? 1 : 0,
alignment: Alignment.bottomRight,
children: [
_toggleLabel(l10n.expandSynopsis),
_toggleLabel(l10n.showLess),
],
),
),
);
Widget _toggleLabel(String label) => Text(
label,
maxLines: _collapsedSynopsisLines,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: _synopsisToggleStyle,
);
void _toggle() => setState(() => _expanded = !_expanded);
}