diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 914c6bd..564ee4f 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -34,6 +34,7 @@ const double _composerTopGap = 8; const double _replyBannerHeight = 46; const double _expandedInputRadius = 12; const double _postButtonHeight = 32; +const double _expandedComposerReserve = 113; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。 class DramaCommentsSheet extends StatefulWidget { @@ -45,22 +46,22 @@ class DramaCommentsSheet extends StatefulWidget { State createState() => _DramaCommentsSheetState(); } -class _DramaCommentsSheetState extends State - with WidgetsBindingObserver { +class _DramaCommentsSheetState extends State { final _input = TextEditingController(); final _inputFocus = FocusNode(); final _keyboardInset = KeyboardInsetNotifier.instance; + final _sheetMeasureKey = GlobalKey(); late final CommentRepository _comments; late final CommentController _controller; DramaComment? _replyTo; int? _serverCount; int _countRequest = 0; - bool _keyboardWasVisible = false; + double? _sheetHeightWithoutKeyboard; + bool _sheetMeasureScheduled = false; @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _inputFocus.addListener(_onFocusChanged); _input.addListener(_onInputChanged); _keyboardInset.addListener(_onKeyboardInsetChanged); @@ -77,7 +78,6 @@ class _DramaCommentsSheetState extends State @override void dispose() { - WidgetsBinding.instance.removeObserver(this); _keyboardInset.removeListener(_onKeyboardInsetChanged); _inputFocus.removeListener(_onFocusChanged); _input.removeListener(_onInputChanged); @@ -98,35 +98,6 @@ class _DramaCommentsSheetState extends State void _onKeyboardInsetChanged() { if (!mounted) return; setState(() {}); - _syncKeyboardVisibility( - math.max( - MediaQuery.viewInsetsOf(context).bottom, - _keyboardInset.value / MediaQuery.devicePixelRatioOf(context), - ), - ); - } - - @override - void didChangeMetrics() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _syncKeyboardVisibility( - math.max( - MediaQuery.viewInsetsOf(context).bottom, - _keyboardInset.value / MediaQuery.devicePixelRatioOf(context), - ), - ); - }); - } - - void _syncKeyboardVisibility(num keyboardHeight) { - if (keyboardHeight > 0) { - _keyboardWasVisible = true; - return; - } - if (!_keyboardWasVisible) return; - _keyboardWasVisible = false; - if (_inputFocus.hasFocus) _inputFocus.unfocus(); } double _keyboardHeight(BuildContext context) => math.max( @@ -134,6 +105,25 @@ class _DramaCommentsSheetState extends State _keyboardInset.value / MediaQuery.devicePixelRatioOf(context), ); + void _measureSheetWithoutKeyboard(double keyboardHeight) { + if (keyboardHeight > 0 || _sheetMeasureScheduled) return; + _sheetMeasureScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _sheetMeasureScheduled = false; + if (!mounted) return; + if (_keyboardHeight(context) > 0) return; + final box = _sheetMeasureKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return; + final height = box.size.height; + if (!height.isFinite || + (_sheetHeightWithoutKeyboard != null && + (height - _sheetHeightWithoutKeyboard!).abs() < 0.5)) { + return; + } + setState(() => _sheetHeightWithoutKeyboard = height); + }); + } + Future _post() async { if (_controller.submitting) return; if (!await ensureSignedIn(context) || !mounted) return; @@ -196,13 +186,12 @@ class _DramaCommentsSheetState extends State : _maxSheetHeight; final focused = _inputFocus.hasFocus; final keyboardHeight = _keyboardHeight(context); - return AnimatedPadding( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: focused ? keyboardHeight : 0), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: focused ? _inputFocus.unfocus : null, + _measureSheetWithoutKeyboard(keyboardHeight); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: focused ? _inputFocus.unfocus : null, + child: SizedBox( + key: _sheetMeasureKey, child: Container( key: const Key('player-comments-sheet'), decoration: const BoxDecoration( @@ -216,22 +205,27 @@ class _DramaCommentsSheetState extends State child: ListenableBuilder( listenable: _controller, builder: (context, _) { - final minHeight = !focused && _controller.comments.isEmpty + final minHeight = _controller.comments.isEmpty ? (_minSheetHeight < maxHeight ? _minSheetHeight : maxHeight) : 0.0; + final pinnedHeight = + keyboardHeight > 0 && _sheetHeightWithoutKeyboard != null + ? math.min(_sheetHeightWithoutKeyboard!, maxHeight) + : null; return ConstrainedBox( key: const Key('comment-sheet-constraints'), - constraints: BoxConstraints( - minHeight: minHeight, - maxHeight: maxHeight, - ), + constraints: pinnedHeight == null + ? BoxConstraints( + minHeight: minHeight, + maxHeight: maxHeight, + ) + : BoxConstraints.tightFor(height: pinnedHeight), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _content( context, - maxHeight: maxHeight, focused: focused, keyboardHeight: keyboardHeight, ), @@ -247,85 +241,86 @@ class _DramaCommentsSheetState extends State Widget _content( BuildContext context, { - required double maxHeight, required bool focused, required double keyboardHeight, }) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; - final replyReserve = _replyTo == null ? 0.0 : _replyBannerHeight; - final listMaxHeight = - maxHeight - - _headerHeight - - _composerHeight - - _composerTopGap - - replyReserve; + final composerReserve = focused + ? _expandedComposerReserve + : _composerHeight + _composerTopGap; return Column( key: const Key('comment-sheet-content'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: focused ? 0 : _headerHeight, + height: _headerHeight, width: double.infinity, - child: focused - ? null - : Stack( - alignment: Alignment.center, - children: [ - Text( - count == null - ? l10n.comments - : l10n.commentsWithCount(count), - key: const Key('comment-count-label'), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - Positioned( - left: 0, - child: SizedBox.square( - dimension: 44, - child: IconButton( - key: const Key('player-comments-close'), - tooltip: l10n.close, - padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context), - icon: const Icon( - Icons.keyboard_arrow_down_rounded, - size: 24, - color: AppColors.cream, - ), - ), - ), - ), - ], + child: Stack( + alignment: Alignment.center, + children: [ + Text( + count == null ? l10n.comments : l10n.commentsWithCount(count), + key: const Key('comment-count-label'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, ), + ), + Positioned( + left: 0, + child: SizedBox.square( + dimension: 44, + child: IconButton( + key: const Key('player-comments-close'), + tooltip: l10n.close, + padding: EdgeInsets.zero, + onPressed: () => Navigator.pop(context), + icon: const Icon( + Icons.keyboard_arrow_down_rounded, + size: 24, + color: AppColors.cream, + ), + ), + ), + ), + ], + ), ), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: focused || listMaxHeight < 0 ? 0 : listMaxHeight, + Flexible( + fit: FlexFit.loose, + child: _list( + l10n, + composerReserve: composerReserve, + keyboardHeight: keyboardHeight, ), - child: focused - ? const SizedBox.shrink() - : _list(l10n, keyboardHeight: keyboardHeight), ), if (_replyTo != null) _replyBanner(l10n, _replyTo!), - Padding( - padding: EdgeInsets.only( - top: focused ? 12 : _composerTopGap, - bottom: focused ? 12 : 0, + AnimatedContainer( + key: const Key('comment-input-lift'), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + transform: Matrix4.translationValues(0, -keyboardHeight, 0), + child: Padding( + padding: EdgeInsets.only( + top: focused ? 12 : _composerTopGap, + bottom: focused ? 12 : 0, + ), + child: _composer(l10n, focused: focused), ), - child: _composer(l10n, focused: focused), ), ], ); } - Widget _list(AppLocalizations l10n, {required double keyboardHeight}) { + Widget _list( + AppLocalizations l10n, { + required double composerReserve, + required double keyboardHeight, + }) { if (_controller.loading && _controller.comments.isEmpty) { return const Padding( padding: EdgeInsets.only(top: 4), @@ -364,7 +359,7 @@ class _DramaCommentsSheetState extends State shrinkWrap: true, padding: EdgeInsets.only( top: 4, - bottom: _composerHeight + keyboardHeight, + bottom: composerReserve + keyboardHeight, ), itemCount: _controller.comments.length + 1, itemBuilder: (context, index) { diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 866e4f7..5382678 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -247,7 +247,8 @@ void main() { expect(tester.widget(input).controller?.text, isEmpty); expect(tester.widget(input).focusNode?.hasFocus, isTrue); expect(tester.widget(input).maxLines, 4); - expect(find.byKey(const Key('comment-list')), findsNothing); + expect(find.byKey(const Key('comment-list')), findsOneWidget); + expect(find.byKey(const Key('comment-count-label')), findsOneWidget); expect( tester .widget(find.byKey(const Key('comment-post'))) @@ -285,8 +286,8 @@ void main() { await tester.tap(find.byKey(const Key('comment-input'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('comment-list')), findsNothing); - expect(find.byKey(const Key('comment-count-label')), findsNothing); + expect(find.byKey(const Key('comment-list')), findsOneWidget); + expect(find.byKey(const Key('comment-count-label')), findsOneWidget); expect(find.byKey(const Key('comment-composer')), findsOneWidget); expect(find.byKey(const Key('comment-input-surface')), findsOneWidget); final editableAfter = tester.state( @@ -309,7 +310,6 @@ void main() { isNull, ); expect(tester.getSize(find.byKey(const Key('comment-post'))).height, 32); - expect(find.byType(IconButton), findsNothing); expect(find.byIcon(Icons.image_outlined), findsNothing); expect(find.byIcon(Icons.emoji_emotions_outlined), findsNothing); expect(find.byIcon(Icons.alternate_email_rounded), findsNothing); @@ -323,24 +323,55 @@ void main() { isNotNull, ); + final sheet = find.byKey(const Key('player-comments-sheet')); + final list = find.byKey(const Key('comment-list')); + final sheetTop = tester.getTopLeft(sheet).dy; + final listTop = tester.getTopLeft(list).dy; tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.pump(); - await tester.pump(const Duration(milliseconds: 160)); - expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); - expect(find.byKey(const Key('comment-list')), findsNothing); + await tester.pump(const Duration(milliseconds: 200)); + expect(sheet, findsOneWidget); + expect(list, findsOneWidget); + expect(find.byKey(const Key('comment-count-label')), findsOneWidget); + final editableDuringKeyboard = tester.state( + find.descendant( + of: find.byKey(const Key('comment-input')), + matching: find.byType(EditableText), + ), + ); + expect(identical(editableAfter, editableDuringKeyboard), isTrue); + expect(tester.getTopLeft(sheet).dy, closeTo(sheetTop, 0.1)); + expect(tester.getTopLeft(list).dy, closeTo(listTop, 0.1)); expect( - tester.getBottomLeft(find.byKey(const Key('player-comments-sheet'))).dy, - closeTo(844 - 180, 0.1), + tester.widget(list).padding?.resolve(TextDirection.ltr).bottom, + greaterThanOrEqualTo(180 + 44), ); expect( tester.getBottomLeft(find.byKey(const Key('comment-input'))).dy, lessThanOrEqualTo(844 - 180), ); + expect( + tester + .widget(find.byKey(const Key('comment-input'))) + .focusNode + ?.hasFocus, + isTrue, + ); tester.view.viewInsets = const FakeViewPadding(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('comment-list')), findsOneWidget); + expect(list, findsOneWidget); + expect(find.byKey(const Key('comment-count-label')), findsOneWidget); expect(find.byKey(const Key('comment-composer')), findsOneWidget); + final editableAfterKeyboard = tester.state( + find.descendant( + of: find.byKey(const Key('comment-input')), + matching: find.byType(EditableText), + ), + ); + expect(identical(editableAfter, editableAfterKeyboard), isTrue); + expect(tester.getTopLeft(sheet).dy, closeTo(sheetTop, 0.1)); + expect(tester.getTopLeft(list).dy, closeTo(listTop, 0.1)); expect( tester .widget(find.byKey(const Key('comment-input'))) @@ -353,7 +384,7 @@ void main() { .widget(find.byKey(const Key('comment-input'))) .focusNode ?.hasFocus, - isFalse, + isTrue, ); });