fix(auth): 登录注册与手机号登录键盘避让
This commit is contained in:
1 parent
86588d7faa
commit
6c2f77926b
6 files changed
+332
-9
No files matched your search
@@ -1,8 +1,29 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// iOS 使用实时 viewInsets;Android adjustNothing 下取 MethodChannel 最大值。
|
||||
double effectiveKeyboardInset(
|
||||
BuildContext context,
|
||||
KeyboardInsetNotifier notifier, {
|
||||
double maxRatio = 0.6,
|
||||
}) {
|
||||
final media = MediaQuery.of(context);
|
||||
final viewInset = media.viewInsets.bottom;
|
||||
final channelInset =
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android
|
||||
? notifier.value / media.devicePixelRatio
|
||||
: 0.0;
|
||||
final inset = math.max(viewInset, channelInset);
|
||||
final maxInset = media.size.height * maxRatio;
|
||||
if (!inset.isFinite || inset > maxInset) return 0;
|
||||
return math.max(0, inset);
|
||||
}
|
||||
|
||||
/// Provides the Android IME height even when `adjustNothing` keeps
|
||||
/// `MediaQuery.viewInsets` at zero.
|
||||
class KeyboardInsetNotifier extends ChangeNotifier {
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/platform/keyboard_inset.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
@@ -101,6 +102,9 @@ class _AuthPageState extends State<AuthPage> {
|
||||
late final TextEditingController _passwordController;
|
||||
late final FocusNode _emailFocus;
|
||||
late final FocusNode _passwordFocus;
|
||||
late final ScrollController _scrollController;
|
||||
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||
double _lastKeyboardInset = 0;
|
||||
bool _obscurePassword = true;
|
||||
bool _emailEntryOpen = false;
|
||||
|
||||
@@ -112,17 +116,87 @@ class _AuthPageState extends State<AuthPage> {
|
||||
_passwordController = TextEditingController();
|
||||
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
|
||||
_passwordFocus = FocusNode(debugLabel: 'auth-password-focus');
|
||||
_scrollController = ScrollController();
|
||||
_emailFocus.addListener(_onFocusChanged);
|
||||
_passwordFocus.addListener(_onFocusChanged);
|
||||
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||
unawaited(_keyboardInset.start());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
||||
_lastKeyboardInset = inset;
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||
_emailFocus.removeListener(_onFocusChanged);
|
||||
_passwordFocus.removeListener(_onFocusChanged);
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyboardInsetChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_emailFocus.hasFocus || _passwordFocus.hasFocus) {
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRevealFocusedField() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _revealFocusedField();
|
||||
});
|
||||
}
|
||||
|
||||
void _revealFocusedField() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final focus = _passwordFocus.hasFocus
|
||||
? _passwordFocus
|
||||
: _emailFocus.hasFocus
|
||||
? _emailFocus
|
||||
: null;
|
||||
final fieldContext = focus?.context;
|
||||
final renderObject = fieldContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if (keyboardInset <= 0) return;
|
||||
final fieldBottom = renderObject
|
||||
.localToGlobal(Offset(0, renderObject.size.height))
|
||||
.dy;
|
||||
final visibleBottom =
|
||||
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
||||
final overlap = fieldBottom - visibleBottom;
|
||||
if (overlap <= 0) return;
|
||||
final position = _scrollController.position;
|
||||
final target = (position.pixels + overlap)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if (target <= position.pixels + 0.5) return;
|
||||
unawaited(
|
||||
_scrollController.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _errorText(AppLocalizations l10n, AuthFieldError error) {
|
||||
switch (error) {
|
||||
case AuthFieldError.emailRequired:
|
||||
@@ -246,12 +320,13 @@ class _AuthPageState extends State<AuthPage> {
|
||||
: _topContentPadding;
|
||||
final bottom =
|
||||
_bottomContentPadding +
|
||||
MediaQuery.viewInsetsOf(context).bottom;
|
||||
effectiveKeyboardInset(context, _keyboardInset);
|
||||
final available = constraints.maxHeight - top - bottom;
|
||||
final minHeight = available > 0 ? available : 0.0;
|
||||
return AutofillGroup(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('auth-scroll'),
|
||||
controller: _scrollController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/platform/keyboard_inset.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -46,7 +47,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
late final TextEditingController _code;
|
||||
late final FocusNode _phoneFocus;
|
||||
late final FocusNode _codeFocus;
|
||||
late final ScrollController _scrollController;
|
||||
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||
Timer? _countdown;
|
||||
double _lastKeyboardInset = 0;
|
||||
bool _signUp = false;
|
||||
bool _sending = false;
|
||||
bool _submitting = false;
|
||||
@@ -61,18 +65,88 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
_code = TextEditingController();
|
||||
_phoneFocus = FocusNode(debugLabel: 'phone-auth-number');
|
||||
_codeFocus = FocusNode(debugLabel: 'phone-auth-code');
|
||||
_scrollController = ScrollController();
|
||||
_phoneFocus.addListener(_onFocusChanged);
|
||||
_codeFocus.addListener(_onFocusChanged);
|
||||
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||
unawaited(_keyboardInset.start());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
||||
_lastKeyboardInset = inset;
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||
_phoneFocus.removeListener(_onFocusChanged);
|
||||
_codeFocus.removeListener(_onFocusChanged);
|
||||
_countdown?.cancel();
|
||||
_phone.dispose();
|
||||
_code.dispose();
|
||||
_phoneFocus.dispose();
|
||||
_codeFocus.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyboardInsetChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_phoneFocus.hasFocus || _codeFocus.hasFocus) {
|
||||
_scheduleRevealFocusedField();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRevealFocusedField() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _revealFocusedField();
|
||||
});
|
||||
}
|
||||
|
||||
void _revealFocusedField() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final focus = _codeFocus.hasFocus
|
||||
? _codeFocus
|
||||
: _phoneFocus.hasFocus
|
||||
? _phoneFocus
|
||||
: null;
|
||||
final fieldContext = focus?.context;
|
||||
final renderObject = fieldContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
||||
if (keyboardInset <= 0) return;
|
||||
final fieldBottom = renderObject
|
||||
.localToGlobal(Offset(0, renderObject.size.height))
|
||||
.dy;
|
||||
final visibleBottom =
|
||||
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
||||
final overlap = fieldBottom - visibleBottom;
|
||||
if (overlap <= 0) return;
|
||||
final position = _scrollController.position;
|
||||
final target = (position.pixels + overlap)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if (target <= position.pixels + 0.5) return;
|
||||
unawaited(
|
||||
_scrollController.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), '');
|
||||
|
||||
bool get _phoneValid => _target.length >= _phoneMinLength;
|
||||
@@ -99,9 +173,8 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
if (!mounted) return;
|
||||
_startCountdown();
|
||||
setState(() => _error = null);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
||||
_codeFocus.requestFocus();
|
||||
} on ApiException catch (error) {
|
||||
if (mounted) setState(() => _error = error.message);
|
||||
@@ -166,16 +239,19 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final bottom = effectiveKeyboardInset(context, _keyboardInset);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottom),
|
||||
child: Container(
|
||||
key: const Key('phone-auth-sheet'),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(22, 10, 22, 26),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -278,9 +354,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
||||
decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith(
|
||||
suffixIcon: _sendButton(l10n),
|
||||
),
|
||||
decoration: _decoration(
|
||||
l10n.authCodeLabel,
|
||||
l10n.authCodeHint,
|
||||
).copyWith(suffixIcon: _sendButton(l10n)),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user