Files
buzz/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

108 lines
4.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// How far a finger must travel downward before the keyboard is dismissed.
///
/// Large enough that reading-speed scrolling never trips it, small enough that
/// a deliberate "push it away" swipe feels immediate.
const keyboardDismissDragThreshold = 48.0;
/// Dismiss the software keyboard when the user deliberately drags downward.
///
/// Flutter only offers `ScrollViewKeyboardDismissBehavior.manual` and `.onDrag`
/// — and `.onDrag` fires the instant a drag begins, so a one-pixel scroll to
/// re-read a message would tear the keyboard away. Neither is available on
/// `ScrollablePositionedList` regardless, which is what both message lists use.
///
/// So: accumulate raw finger travel and only unfocus once a downward drag
/// clears [keyboardDismissDragThreshold]. Any upward movement resets the
/// accumulator, so a scroll that wanders up and down never adds up to a
/// dismissal.
///
/// This tracks the *finger*, not the scroll direction, so it behaves the same
/// in the reversed channel timeline and the top-anchored thread list. It is not
/// interactive dismissal — the keyboard animates away on the OS's own curve
/// rather than following the finger, which Flutter can't do without a native
/// plugin (iOS `UIScrollView.keyboardDismissMode`, Android
/// `WindowInsetsAnimationController`).
class KeyboardDismissOnDrag extends HookWidget {
final Widget child;
final VoidCallback? onUserScrollStart;
const KeyboardDismissOnDrag({
super.key,
this.onUserScrollStart,
required this.child,
});
@override
Widget build(BuildContext context) {
// A ref, not state: accumulating travel must never trigger a rebuild of
// the message list this wraps.
final downwardTravel = useRef(0.0);
bool handle(ScrollNotification notification) {
if (notification is ScrollStartNotification) {
if (notification.dragDetails != null) onUserScrollStart?.call();
downwardTravel.value = 0;
return false;
}
if (notification is ScrollEndNotification) {
downwardTravel.value = 0;
return false;
}
// Overscroll counts as much as ordinary scrolling. You focus the composer
// at the resting end of the list far more often than not, and there a
// downward drag moves the position nowhere — it is pure overscroll, and
// reports `OverscrollNotification` instead. Listening only for
// `ScrollUpdateNotification` meant the most common dismissal gesture in
// both lists produced no notification this saw at all.
final DragUpdateDetails? drag;
if (notification is ScrollUpdateNotification) {
drag = notification.dragDetails;
} else if (notification is OverscrollNotification) {
drag = notification.dragDetails;
} else {
return false;
}
// Null during a momentum fling — only a finger still on the glass should
// dismiss, so a fling that coasts downward leaves the keyboard up.
if (drag == null) {
downwardTravel.value = 0;
return false;
}
final dy = drag.delta.dy;
if (dy <= 0) {
downwardTravel.value = 0;
return false;
}
downwardTravel.value += dy;
if (downwardTravel.value < keyboardDismissDragThreshold) return false;
downwardTravel.value = 0;
dismissKeyboard(context);
return false;
}
return NotificationListener<ScrollNotification>(
onNotification: handle,
child: child,
);
}
}
/// Drop focus so the OS retracts the keyboard. No-op when nothing is focused
/// or the keyboard is already down, so callers can fire it freely.
void dismissKeyboard(BuildContext context) {
// Read the view's own insets, not MediaQuery's. `Scaffold` strips the bottom
// view inset from its body while `resizeToAvoidBottomInset` is on — it has
// already resized to account for it — so a MediaQuery read from anywhere
// inside the body reports 0 whether the keyboard is up or not, which made
// this gate a permanent no-op for both message lists and the compose bar.
if (View.of(context).viewInsets.bottom <= 0) return;
FocusManager.instance.primaryFocus?.unfocus();
}