keyboard: sampled native curve, interactive dismiss, scroll freeze
- Dart-side animation replay using exact native curve (21-point lookup table sampled from CADisplayLink) with 16ms start offset and 10ms shorter duration to stay ahead of the native animation pipeline. - Snap-back reads bounds presentation layer so height doesn't jump. - Dismiss defers resignFirstResponder until bounds animation completes, with immediate Dart close animation via animTarget/animGeneration. - enableInteractiveDismiss accepts trackingInset to widen the gesture zone above the keyboard (for composer height). - FFI: anim_target, anim_duration, anim_gen, system_keyboard_height, is_tracking exposed for Dart animation and scroll physics.
This commit is contained in:
406
ios/Classes/KeyboardPlugin.swift
Normal file
406
ios/Classes/KeyboardPlugin.swift
Normal file
@@ -0,0 +1,406 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// MARK: - FFI interface
|
||||
|
||||
public typealias WakeCallback = @convention(c) () -> Void
|
||||
|
||||
/// Returns the current keyboard height by reading the presentation layer directly.
|
||||
/// Called by Dart's persistent frame callback — zero latency.
|
||||
@_cdecl("ux_keyboard_height")
|
||||
public func ux_keyboard_height() -> Double {
|
||||
guard let plugin = KeyboardPlugin.shared else { return 0 }
|
||||
|
||||
// During interactive pan, we track the offset ourselves
|
||||
if plugin.isTracking {
|
||||
return max(0, Double(plugin.keyboardFullHeight - plugin.interactiveOffset))
|
||||
}
|
||||
|
||||
// During dismiss or snap-back animation, read bounds offset from presentation layer
|
||||
// (frame is unaffected by bounds.origin, so the normal path would report full height)
|
||||
if plugin.isDismissing || plugin.snapBackAnimator != nil {
|
||||
guard let kbView = plugin.keyboardView else { return 0 }
|
||||
let boundsY = kbView.layer.presentation()?.bounds.origin.y ?? 0
|
||||
return max(0, Double(plugin.keyboardFullHeight + Double(boundsY)))
|
||||
}
|
||||
|
||||
// Otherwise read the actual keyboard view position
|
||||
guard let kbView = plugin.keyboardView else {
|
||||
return Double(plugin.keyboardFullHeight)
|
||||
}
|
||||
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
// presentation() gives the interpolated value during CoreAnimation
|
||||
if let presentation = kbView.layer.presentation() {
|
||||
return max(0, Double(screenHeight - presentation.frame.origin.y))
|
||||
}
|
||||
return max(0, Double(screenHeight - kbView.frame.origin.y))
|
||||
}
|
||||
|
||||
/// Returns true when interactive dismiss pan is active.
|
||||
/// Flutter should stop scrolling the message list.
|
||||
@_cdecl("ux_is_tracking")
|
||||
public func ux_is_tracking() -> Int32 {
|
||||
return (KeyboardPlugin.shared?.isTracking ?? false) ? 1 : 0
|
||||
}
|
||||
|
||||
/// Returns the system-reported keyboard height (from the last notification).
|
||||
/// Use this as source of truth for "is keyboard open" state.
|
||||
@_cdecl("ux_system_keyboard_height")
|
||||
public func ux_system_keyboard_height() -> Double {
|
||||
return Double(KeyboardPlugin.shared?.keyboardFullHeight ?? 0)
|
||||
}
|
||||
|
||||
@_cdecl("ux_register_wake_callback")
|
||||
public func ux_register_wake_callback(_ cb: @escaping WakeCallback) {
|
||||
KeyboardPlugin.shared?.wakeCallback = cb
|
||||
}
|
||||
|
||||
@_cdecl("ux_enable_interactive_dismiss")
|
||||
public func ux_enable_interactive_dismiss(_ trackingInset: Double) {
|
||||
KeyboardPlugin.shared?.enableInteractiveDismiss(trackingInset: CGFloat(trackingInset))
|
||||
}
|
||||
|
||||
@_cdecl("ux_disable_interactive_dismiss")
|
||||
public func ux_disable_interactive_dismiss() {
|
||||
KeyboardPlugin.shared?.disableInteractiveDismiss()
|
||||
}
|
||||
|
||||
/// Animation params — Dart replays the same animation internally.
|
||||
@_cdecl("ux_keyboard_anim_target")
|
||||
public func ux_keyboard_anim_target() -> Double {
|
||||
return Double(KeyboardPlugin.shared?.animTarget ?? 0)
|
||||
}
|
||||
|
||||
@_cdecl("ux_keyboard_anim_duration")
|
||||
public func ux_keyboard_anim_duration() -> Double {
|
||||
return KeyboardPlugin.shared?.animDuration ?? 0
|
||||
}
|
||||
|
||||
/// Incremented each time a new keyboard animation starts.
|
||||
/// Dart compares against its own copy to detect new animations.
|
||||
@_cdecl("ux_keyboard_anim_gen")
|
||||
public func ux_keyboard_anim_gen() -> Int32 {
|
||||
return KeyboardPlugin.shared?.animGeneration ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
public class KeyboardPlugin: NSObject, FlutterPlugin {
|
||||
fileprivate static var shared: KeyboardPlugin?
|
||||
|
||||
fileprivate var wakeCallback: WakeCallback?
|
||||
private var isObserving = false
|
||||
private var gestureEnabled = false
|
||||
private var panRecognizer: UIPanGestureRecognizer?
|
||||
|
||||
// Keyboard state
|
||||
fileprivate var keyboardView: UIView?
|
||||
fileprivate var keyboardFullHeight: CGFloat = 0
|
||||
fileprivate var isTracking = false
|
||||
fileprivate var isDismissing = false
|
||||
fileprivate var interactiveOffset: CGFloat = 0
|
||||
|
||||
// Pan gesture
|
||||
private var keyboardOriginY: CGFloat = 0
|
||||
fileprivate var snapBackAnimator: UIViewPropertyAnimator?
|
||||
private var trackingInset: CGFloat = 0
|
||||
|
||||
// Animation params — passed to Dart so it can replay the same animation
|
||||
fileprivate var animTarget: CGFloat = 0
|
||||
fileprivate var animDuration: Double = 0
|
||||
fileprivate var animGeneration: Int32 = 0
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let instance = KeyboardPlugin()
|
||||
KeyboardPlugin.shared = instance
|
||||
|
||||
instance.startObserving()
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
/// Wake Dart so it reads the height on its next frame
|
||||
private func wake() {
|
||||
wakeCallback?()
|
||||
}
|
||||
|
||||
// MARK: - Keyboard Notifications
|
||||
|
||||
private func startObserving() {
|
||||
guard !isObserving else { return }
|
||||
isObserving = true
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillChange),
|
||||
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillShow),
|
||||
name: UIResponder.keyboardWillShowNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillChange(_ notification: Notification) {
|
||||
guard !isTracking else { return }
|
||||
|
||||
guard let userInfo = notification.userInfo else { return }
|
||||
let endFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
let height = max(0, screenHeight - endFrame.origin.y)
|
||||
|
||||
// Pass animation params to Dart so it can replay the same animation
|
||||
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
||||
animTarget = height
|
||||
animDuration = duration
|
||||
animGeneration &+= 1
|
||||
|
||||
if height > 0 {
|
||||
keyboardFullHeight = height
|
||||
} else {
|
||||
keyboardFullHeight = 0
|
||||
isDismissing = false
|
||||
// Reset bounds when system confirms keyboard is gone
|
||||
keyboardView?.layer.bounds.origin = .zero
|
||||
}
|
||||
|
||||
if keyboardView == nil {
|
||||
discoverKeyboardView()
|
||||
}
|
||||
|
||||
// Wake Dart so it starts the animation
|
||||
wake()
|
||||
}
|
||||
|
||||
@objc private func keyboardWillShow(_ notification: Notification) {
|
||||
discoverKeyboardView()
|
||||
}
|
||||
|
||||
// MARK: - Keyboard View Discovery
|
||||
|
||||
private func discoverKeyboardView() {
|
||||
keyboardView = nil
|
||||
|
||||
// Private API — same as Telegram (UIKitRuntimeUtils/UIViewController+Navigation.m)
|
||||
if let windowClass = NSClassFromString("UIRemoteKeyboardWindow") as? NSObject.Type {
|
||||
let sel = NSSelectorFromString("remoteKeyboardWindowForScreen:create:")
|
||||
if windowClass.responds(to: sel) {
|
||||
if let window = windowClass.perform(sel, with: UIScreen.main, with: false)?.takeUnretainedValue() as? UIWindow {
|
||||
if let found = findKeyboardHostView(in: window) {
|
||||
keyboardView = found
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: walk visible windows
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for window in windowScene.windows {
|
||||
let name = NSStringFromClass(type(of: window))
|
||||
let isKB = (name.hasPrefix("UI") && name.hasSuffix("RemoteKeyboardWindow")) ||
|
||||
(name.hasPrefix("UI") && name.hasSuffix("TextEffectsWindow"))
|
||||
guard isKB else { continue }
|
||||
if let found = findKeyboardHostView(in: window) {
|
||||
keyboardView = found
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findKeyboardHostView(in view: UIView) -> UIView? {
|
||||
let name = NSStringFromClass(type(of: view))
|
||||
if (name.hasPrefix("UI") && name.hasSuffix("InputSetHostView")) ||
|
||||
(name.hasPrefix("UI") && name.hasSuffix("KeyboardItemContainerView")) {
|
||||
return view
|
||||
}
|
||||
for subview in view.subviews {
|
||||
if let found = findKeyboardHostView(in: subview) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Interactive Dismiss
|
||||
|
||||
fileprivate func enableInteractiveDismiss(trackingInset: CGFloat = 0) {
|
||||
self.trackingInset = trackingInset
|
||||
guard panRecognizer == nil else {
|
||||
gestureEnabled = true
|
||||
return
|
||||
}
|
||||
gestureEnabled = true
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.setupPanGesture()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func disableInteractiveDismiss() {
|
||||
gestureEnabled = false
|
||||
if let recognizer = panRecognizer {
|
||||
recognizer.view?.removeGestureRecognizer(recognizer)
|
||||
panRecognizer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPanGesture() {
|
||||
var targetView: UIView?
|
||||
|
||||
if let window = UIApplication.shared.delegate?.window ?? nil {
|
||||
targetView = window.rootViewController?.view ?? window
|
||||
} else {
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
if let ws = scene as? UIWindowScene {
|
||||
for window in ws.windows where window.isKeyWindow {
|
||||
targetView = window.rootViewController?.view ?? window
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let view = targetView else { return }
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
pan.cancelsTouchesInView = false
|
||||
pan.delaysTouchesBegan = false
|
||||
pan.delaysTouchesEnded = false
|
||||
pan.delegate = self
|
||||
view.addGestureRecognizer(pan)
|
||||
panRecognizer = pan
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
guard gestureEnabled, let view = gesture.view else { return }
|
||||
|
||||
let location = gesture.location(in: view)
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
break
|
||||
|
||||
case .changed:
|
||||
if !isTracking {
|
||||
// Use system keyboard height as source of truth
|
||||
guard keyboardFullHeight > 0, keyboardView != nil else { return }
|
||||
|
||||
let keyboardTop = screenHeight - keyboardFullHeight - trackingInset
|
||||
guard location.y > keyboardTop else { return }
|
||||
|
||||
snapBackAnimator?.stopAnimation(true)
|
||||
snapBackAnimator = nil
|
||||
|
||||
isTracking = true
|
||||
interactiveOffset = 0
|
||||
keyboardOriginY = location.y
|
||||
|
||||
// Wake Dart so it knows tracking started (for scroll stop)
|
||||
wake()
|
||||
}
|
||||
|
||||
interactiveOffset = max(0, location.y - keyboardOriginY)
|
||||
|
||||
// Move the keyboard view — same as Telegram's KeyboardManager
|
||||
if let kbView = keyboardView {
|
||||
kbView.layer.bounds = CGRect(
|
||||
origin: CGPoint(x: 0, y: -interactiveOffset),
|
||||
size: kbView.layer.bounds.size
|
||||
)
|
||||
}
|
||||
|
||||
// Wake Dart to read the new height
|
||||
wake()
|
||||
|
||||
case .ended, .cancelled:
|
||||
guard isTracking else { return }
|
||||
isTracking = false
|
||||
|
||||
let velocity = gesture.velocity(in: view).y
|
||||
let dismissThreshold = keyboardFullHeight * 0.4
|
||||
|
||||
if velocity > 100 || interactiveOffset > dismissThreshold {
|
||||
dismissKeyboard()
|
||||
} else {
|
||||
snapBack()
|
||||
}
|
||||
interactiveOffset = 0
|
||||
|
||||
// Wake Dart so it knows tracking ended
|
||||
wake()
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissKeyboard() {
|
||||
isDismissing = true
|
||||
let fullHeight = keyboardFullHeight
|
||||
|
||||
// Tell Dart to start close animation immediately
|
||||
animTarget = 0
|
||||
animDuration = 0.25
|
||||
animGeneration &+= 1
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.25, dampingRatio: 0.9) { [weak self] in
|
||||
guard let kbView = self?.keyboardView else { return }
|
||||
kbView.layer.bounds = CGRect(
|
||||
origin: CGPoint(x: 0, y: -fullHeight),
|
||||
size: kbView.layer.bounds.size
|
||||
)
|
||||
}
|
||||
animator.addCompletion { [weak self] _ in
|
||||
self?.isDismissing = false
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
private func snapBack() {
|
||||
guard let kbView = keyboardView else { return }
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.25, dampingRatio: 0.9) {
|
||||
kbView.layer.bounds = CGRect(
|
||||
origin: .zero,
|
||||
size: kbView.layer.bounds.size
|
||||
)
|
||||
}
|
||||
snapBackAnimator = animator
|
||||
animator.addCompletion { [weak self] _ in
|
||||
self?.snapBackAnimator = nil
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
// MARK: - Warmup
|
||||
|
||||
private static func warmup() {
|
||||
let field = UITextField(frame: .zero)
|
||||
let window = UIWindow(frame: .zero)
|
||||
window.addSubview(field)
|
||||
field.becomeFirstResponder()
|
||||
field.resignFirstResponder()
|
||||
window.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
extension KeyboardPlugin: UIGestureRecognizerDelegate {
|
||||
public func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
165
lib/src/keyboard.dart
Normal file
165
lib/src/keyboard.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
final bool _isIOS = Platform.isIOS;
|
||||
|
||||
DynamicLibrary? _initLib() {
|
||||
if (!_isIOS) return null;
|
||||
return DynamicLibrary.process();
|
||||
}
|
||||
|
||||
final DynamicLibrary? _lib = _initLib();
|
||||
|
||||
double Function()? _lookupDouble(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Double Function()>>(name).asFunction<double Function()>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int Function()? _lookupInt32(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Int32 Function()>>(name).asFunction<int Function()>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void Function()? _lookupVoid(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Void Function()>>(name).asFunction<void Function()>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final _uxKeyboardHeight = _lookupDouble('ux_keyboard_height');
|
||||
final _uxSystemHeight = _lookupDouble('ux_system_keyboard_height');
|
||||
final _uxIsTracking = _lookupInt32('ux_is_tracking');
|
||||
final _uxAnimTarget = _lookupDouble('ux_keyboard_anim_target');
|
||||
final _uxAnimDuration = _lookupDouble('ux_keyboard_anim_duration');
|
||||
final _uxAnimGen = _lookupInt32('ux_keyboard_anim_gen');
|
||||
|
||||
void Function(double)? _lookupEnableInteractiveDismiss() {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!
|
||||
.lookup<NativeFunction<Void Function(Double)>>('ux_enable_interactive_dismiss')
|
||||
.asFunction<void Function(double)>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final _uxEnableInteractiveDismiss = _lookupEnableInteractiveDismiss();
|
||||
final _uxDisableInteractiveDismiss = _lookupVoid('ux_disable_interactive_dismiss');
|
||||
|
||||
/// iOS keyboard animation curve — sampled from native CADisplayLink.
|
||||
/// 21 points at t = 0.00, 0.05, ..., 1.00. Averaged from multiple open/close cycles.
|
||||
const _kKeyboardSamples = <double>[
|
||||
0.0000, 0.0618, 0.1991, 0.3618, 0.5123, // t=0.00..0.20
|
||||
0.6375, 0.7362, 0.8112, 0.8664, 0.9062, // t=0.25..0.45
|
||||
0.9347, 0.9550, 0.9692, 0.9790, 0.9858, // t=0.50..0.70
|
||||
0.9904, 0.9935, 0.9956, 0.9971, 0.9980, // t=0.75..0.95
|
||||
0.9993, // t=1.00
|
||||
];
|
||||
|
||||
const Curve _kKeyboardCurve = _SampledCurve(_kKeyboardSamples);
|
||||
|
||||
class _SampledCurve extends Curve {
|
||||
const _SampledCurve(this._samples);
|
||||
final List<double> _samples;
|
||||
|
||||
@override
|
||||
double transformInternal(double t) {
|
||||
final n = _samples.length - 1;
|
||||
final scaled = t * n;
|
||||
final i = scaled.floor().clamp(0, n - 1);
|
||||
final frac = scaled - i;
|
||||
return _samples[i] + frac * (_samples[i + 1] - _samples[i]);
|
||||
}
|
||||
}
|
||||
|
||||
class UxKeyboard with ChangeNotifier {
|
||||
UxKeyboard._() {
|
||||
if (!_isIOS) return;
|
||||
SchedulerBinding.instance.addPersistentFrameCallback(_onFrame);
|
||||
}
|
||||
|
||||
static final UxKeyboard instance = UxKeyboard._();
|
||||
|
||||
double _height = 0;
|
||||
|
||||
double get height => _height;
|
||||
double get systemHeight => _uxSystemHeight?.call() ?? 0;
|
||||
bool get isOpen => _height > 0;
|
||||
bool get isTracking => (_uxIsTracking?.call() ?? 0) > 0;
|
||||
|
||||
// Animation state — replays the keyboard's own animation inside Flutter.
|
||||
int _lastAnimGen = 0;
|
||||
double _animFrom = 0;
|
||||
double _animTo = 0;
|
||||
double _animDuration = 0;
|
||||
double _animStartTime = 0; // seconds, from frame timestamp
|
||||
bool _isAnimating = false;
|
||||
|
||||
void _onFrame(Duration timestamp) {
|
||||
if (_uxKeyboardHeight == null) return;
|
||||
|
||||
final ts = timestamp.inMicroseconds / Duration.microsecondsPerSecond;
|
||||
|
||||
// Detect new keyboard animation from native
|
||||
final gen = _uxAnimGen?.call() ?? 0;
|
||||
if (gen != _lastAnimGen) {
|
||||
_lastAnimGen = gen;
|
||||
final target = _uxAnimTarget?.call() ?? 0;
|
||||
final duration = _uxAnimDuration?.call() ?? 0;
|
||||
if (duration > 0) {
|
||||
_animFrom = _height;
|
||||
_animTo = target;
|
||||
_animDuration = duration - 0.01; // finish 10ms ahead of native
|
||||
_animStartTime = ts - 0.016; // compensate 2-frame pipeline delay
|
||||
_isAnimating = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Abort animation if interactive tracking started
|
||||
if (_isAnimating && (_uxIsTracking?.call() ?? 0) > 0) {
|
||||
_isAnimating = false;
|
||||
}
|
||||
|
||||
double h;
|
||||
if (_isAnimating) {
|
||||
final elapsed = ts - _animStartTime;
|
||||
final t = (elapsed / _animDuration).clamp(0.0, 1.0);
|
||||
h = _animFrom + (_animTo - _animFrom) * _kKeyboardCurve.transform(t);
|
||||
if (t >= 1.0) {
|
||||
_isAnimating = false;
|
||||
h = _animTo;
|
||||
}
|
||||
} else {
|
||||
// Fallback: read FFI directly (interactive dismiss, snap-back, etc.)
|
||||
h = _uxKeyboardHeight!();
|
||||
}
|
||||
|
||||
if ((h - _height).abs() > 0.5) {
|
||||
_height = h;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (_isAnimating) {
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
}
|
||||
|
||||
void enableInteractiveDismiss({double trackingInset = 0}) => _uxEnableInteractiveDismiss?.call(trackingInset);
|
||||
void disableInteractiveDismiss() => _uxDisableInteractiveDismiss?.call();
|
||||
}
|
||||
Reference in New Issue
Block a user