// 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 createState() => _ExpandableSynopsisState(); } class _ExpandableSynopsisState extends State { 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); }