fix(comment): 修复 iOS 评论输入弹窗键盘避让

This commit is contained in:
Zhou Weidong committed 2026-10-21 18:46:43 +08:00
1 parent dfc33027d8
commit 349ea12404
3 files changed
+222 -121

No files matched your search

+35
View File
@@ -33,3 +33,38 @@ Running Gradle task 'assembleDebug'... 24.8s
解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功;
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
## 2026-10-21 K97 修复 iOS 评论输入弹窗键盘避让
- 已完整读取 K97 `docs/plans/INBOX.md`。
- 已从最新 `origin/develop` 创建 `fix/k97-ios-comment-keyboard`。
- 计划见 FruitServer `docs/plans/2026-10-21-K97-ios-comment-keyboard.md`。
- Android 保留既有 pinned/channel 策略;iOS 与其他平台改用实时
`MediaQuery.viewInsets.bottom`,并补 iOS 键盘避让测试。
- 已完成 `_isAndroid` 平台分流;Android 的 channel、80ms 防抖和 pinned 逻辑保持不变,
iOS/其他平台直接读取实时 viewInsets,超过屏幕 60% 的异常值按 0 处理。
- 面板已使用 160ms `easeOutCubic` AnimatedPadding;Android 不包裹 SafeArea,
iOS/其他平台使用 `SafeArea(top: false)` 处理 home indicator。
- 已补 iOS insets=300/0 测试,发表按钮在键盘上方;Android pinned 既有用例全部通过。
门禁原始输出:
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:54 +555: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 26.2s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
$ flutter build ios --simulator --no-codesign
Xcode build done. 44.0s
✓ Built build/ios/iphonesimulator/Runner.app
```
解读:静态分析零告警,555 项全量测试通过,Android 与 iOS simulator 构建成功;
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
@@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -54,6 +55,9 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
bool _useChannelInset = false;
bool _keyboardVisible = false;
bool get _isAndroid =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
@override
void initState() {
super.initState();
@@ -66,6 +70,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isAndroid) return;
if (!_pinnedInitialized) {
_pinnedInitialized = true;
_pinnedKeyboardInset =
@@ -75,10 +80,12 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
}
Future<void> _prepareInput() async {
await _keyboardInset.start();
if (!mounted) return;
_insetBaseline = _keyboardInset.value;
_insetReady = true;
if (_isAndroid) {
await _keyboardInset.start();
if (!mounted) return;
_insetBaseline = _keyboardInset.value;
_insetReady = true;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_inputFocus.requestFocus();
@@ -112,7 +119,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
}
void _onKeyboardInsetChanged() {
if (!mounted || !_insetReady) return;
if (!_isAndroid || !mounted || !_insetReady) return;
if (_keyboardInset.value != _insetBaseline) {
_useChannelInset = true;
}
@@ -121,6 +128,9 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
double _keyboardHeight(BuildContext context) {
final viewInset = MediaQuery.viewInsetsOf(context).bottom;
if (!_isAndroid) {
return _normalizeKeyboardInset(viewInset) ?? 0;
}
final channelInset = _useChannelInset
? _keyboardInset.value / MediaQuery.devicePixelRatioOf(context)
: 0.0;
@@ -134,6 +144,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
}
void _trackKeyboardInset(double inset) {
if (!_isAndroid) return;
final visible = inset > 0;
if (visible == _keyboardVisible) return;
_keyboardVisible = visible;
@@ -147,6 +158,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
}
void _captureSettledKeyboardInset() {
if (!_isAndroid) return;
_keyboardSettleTimer = null;
if (!mounted) return;
final inset = _keyboardHeight(context);
@@ -193,6 +205,109 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
final hasContent = _input.text.trim().isNotEmpty;
final canPost = signedIn && hasContent && !widget.controller.submitting;
final multiline = _input.text.contains('\n');
final bottomInset = _isAndroid
? _pinnedKeyboardInset
: _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0;
final content = Padding(
key: const Key('comment-composer-sheet'),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: TextFieldTapRegion(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.replyTo != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
l10n.replyingTo(
widget.replyTo!.nickname.isEmpty
? l10n.anonymousUser
: widget.replyTo!.nickname,
),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
Container(
decoration: BoxDecoration(
color: AppColors.commentInput,
borderRadius: BorderRadius.circular(_inputRadius),
),
child: TextField(
key: const Key('comment-composer-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
minLines: 1,
maxLines: 4,
maxLength: _maxCommentLength,
textAlignVertical: multiline
? TextAlignVertical.top
: TextAlignVertical.center,
style: TextStyle(
color: AppColors.cream,
fontSize: 15,
height: multiline ? 1.4 : null,
),
cursorColor: AppColors.blue,
decoration: InputDecoration(
hintText: signedIn
? l10n.writeComment
: l10n.commentLoginHint,
hintStyle: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
counterText: '',
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onTap: signedIn ? null : _authenticate,
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: _postButtonHeight,
child: TextButton(
key: const Key('comment-composer-post'),
onPressed: canPost ? _submit : null,
style: TextButton.styleFrom(
minimumSize: const Size(0, _postButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 16),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: hasContent
? AppColors.blue.withValues(alpha: 0.12)
: AppColors.raised,
foregroundColor: AppColors.blue,
disabledForegroundColor: AppColors.muted,
disabledBackgroundColor: AppColors.raised,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
l10n.postComment,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
return FocusScope(
key: const Key('comment-composer-focus-scope'),
@@ -222,122 +337,14 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
child: SafeArea(
top: false,
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: _pinnedKeyboardInset),
child: Padding(
key: const Key('comment-composer-sheet'),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: TextFieldTapRegion(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.replyTo != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
l10n.replyingTo(
widget.replyTo!.nickname.isEmpty
? l10n.anonymousUser
: widget.replyTo!.nickname,
),
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
),
Container(
decoration: BoxDecoration(
color: AppColors.commentInput,
borderRadius: BorderRadius.circular(
_inputRadius,
),
),
child: TextField(
key: const Key('comment-composer-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
minLines: 1,
maxLines: 4,
maxLength: _maxCommentLength,
textAlignVertical: multiline
? TextAlignVertical.top
: TextAlignVertical.center,
style: TextStyle(
color: AppColors.cream,
fontSize: 15,
height: multiline ? 1.4 : null,
),
cursorColor: AppColors.blue,
decoration: InputDecoration(
hintText: signedIn
? l10n.writeComment
: l10n.commentLoginHint,
hintStyle: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
counterText: '',
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onTap: signedIn ? null : _authenticate,
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: _postButtonHeight,
child: TextButton(
key: const Key('comment-composer-post'),
onPressed: canPost ? _submit : null,
style: TextButton.styleFrom(
minimumSize: const Size(
0,
_postButtonHeight,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
backgroundColor: hasContent
? AppColors.blue.withValues(alpha: 0.12)
: AppColors.raised,
foregroundColor: AppColors.blue,
disabledForegroundColor: AppColors.muted,
disabledBackgroundColor: AppColors.raised,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
l10n.postComment,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
),
),
child: AnimatedPadding(
key: const Key('comment-composer-keyboard-padding'),
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(bottom: bottomInset),
child: _isAndroid
? content
: SafeArea(top: false, child: content),
),
),
),
@@ -88,6 +88,53 @@ void main() {
});
});
testWidgets('should_follow_live_view_insets_on_ios', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
final repository = FakeCommentRepository(pages: [const CommentPage()]);
await _pumpSheet(tester, repository: repository);
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
await tester.pumpAndSettle();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
await tester.pump(const Duration(milliseconds: 160));
final animatedPadding = tester.widget<AnimatedPadding>(
find.byKey(const Key('comment-composer-keyboard-padding')),
);
expect((animatedPadding.padding as EdgeInsets).bottom, 300);
expect(
tester.getBottomLeft(find.byKey(const Key('comment-composer-post'))).dy,
lessThanOrEqualTo(844 - 300),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('should_use_zero_live_inset_when_ios_keyboard_is_hidden', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final repository = FakeCommentRepository(pages: [const CommentPage()]);
await _pumpSheet(tester, repository: repository);
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
await tester.pumpAndSettle();
final animatedPadding = tester.widget<AnimatedPadding>(
find.byKey(const Key('comment-composer-keyboard-padding')),
);
expect((animatedPadding.padding as EdgeInsets).bottom, 0);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('should_gate_guest_comment_input_without_focus', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
@@ -402,6 +449,8 @@ void main() {
testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -462,6 +511,7 @@ void main() {
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
await tester.pump(const Duration(milliseconds: 200));
final keyboardUpInputTop = tester.getTopLeft(composer).dy;
final keyboardUpBottom = tester.getBottomLeft(composer).dy;
@@ -497,11 +547,14 @@ void main() {
await tester.pumpAndSettle();
expect(composer, findsOneWidget);
expect(tester.getBottomLeft(composer).dy, closeTo(844, 0.1));
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'should_keep_composer_input_position_when_safe_area_padding_changes',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding();
@@ -525,6 +578,7 @@ void main() {
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
await tester.pump(const Duration(milliseconds: 200));
final keyboardUpInputRect = tester.getRect(input);
tester.view.viewInsets = const FakeViewPadding();
@@ -544,12 +598,15 @@ void main() {
keyboardClosedInputRect.height,
closeTo(keyboardUpInputRect.height, 0.1),
);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'should_keep_composer_position_when_keyboard_insets_change_after_settling',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 2200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -573,6 +630,7 @@ void main() {
tester.view.viewInsets = const FakeViewPadding(bottom: 1100);
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
await tester.pump(const Duration(milliseconds: 200));
final settledTop = tester.getTopLeft(composer).dy;
final settledBottom = tester.getBottomLeft(composer).dy;
final settledHeight = tester.getSize(composer).height;
@@ -598,6 +656,7 @@ void main() {
expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1));
expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1));
expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1));
debugDefaultTargetPlatformOverride = null;
},
);