feat(player): 重做播放页信息层级与互动栏点击动效
This commit is contained in:
1 parent
aed9ee2b01
commit
fd2ba0bf43
15 files changed
+2074
-551
No files matched your search
@@ -0,0 +1,166 @@
|
||||
// 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(
|
||||
color: AppColors.overlaySecondary,
|
||||
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,
|
||||
});
|
||||
|
||||
/// 按当前语言获取的简介正文。
|
||||
final String synopsis;
|
||||
|
||||
/// 当前页面的组件标识前缀。
|
||||
final String keyPrefix;
|
||||
|
||||
/// 展开后附加的声明等内容。
|
||||
final Widget? expandedFooter;
|
||||
|
||||
@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, _synopsisStyle);
|
||||
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');
|
||||
|
||||
Widget _body() {
|
||||
final text = Text.rich(
|
||||
TextSpan(text: widget.synopsis, style: _synopsisStyle),
|
||||
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
|
||||
style: _synopsisStyle,
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user