├── README.md ├── appdelegate_stuff.swift └── kbtestwidget.dart /README.md: -------------------------------------------------------------------------------- 1 | # crazy_cool_keyboard_for_flutter 2 | Lets Flutter take control over the iOS soft keyboard position. This allows for a smoother UI experience and lets you swipe the keyboard away too! 3 | 4 | So far it's just a proof of concept and not production ready and is meant to inspire you all. Please don't hesitate to improve on this. For now I don't really have much time to improve it myself unfortunately. 5 | 6 | To install: 7 | - wrap the widget around a Scaffold and set its avoidBottomViewInsets to false 8 | - add the Swift code inside the AppDelegate class in AppDelegate.swift 9 | 10 | Make sure you have textfield or something that triggers the on screen keyboard. You should be able to swipe the keyboard away, just make sure you start swiping from outside the keyboard. 11 | 12 | Here the keyboard bounces using a Flutter bounce curve. You can easily choose you own curve of course: 13 | 14 | ezgif com-optimize-4 15 | 16 | 17 | In this example you need to drag past half way to trigger a close, or else it bounces back: 18 | 19 | ezgif com-optimize-5 20 | -------------------------------------------------------------------------------- /appdelegate_stuff.swift: -------------------------------------------------------------------------------- 1 | var methodChannel: FlutterMethodChannel? 2 | 3 | override func applicationDidBecomeActive(_ application: UIApplication) { 4 | 5 | if methodChannel == nil, let rootViewController = window?.rootViewController as? FlutterViewController { 6 | 7 | methodChannel = FlutterMethodChannel(name: "kbtestwidget", binaryMessenger: rootViewController as! FlutterBinaryMessenger) 8 | 9 | //this method basically sets the y position of the keyboard to what Flutter says it to 10 | methodChannel?.setMethodCallHandler {(call: FlutterMethodCall, result: FlutterResult) -> Void in 11 | if UIApplication.shared.windows.count > 2, let kbWindow = UIApplication.shared.windows.last { 12 | DispatchQueue.main.async(execute: { 13 | kbWindow.frame = CGRect.init(x: kbWindow.frame.minX, y: call.arguments as! CGFloat, width: kbWindow.frame.width, height: kbWindow.frame.height) 14 | }) 15 | } 16 | result(true) 17 | } 18 | NotificationCenter.default.addObserver(self, selector: #selector(keyboardNotification(notification:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) 19 | } 20 | 21 | } 22 | 23 | @objc func keyboardNotification(notification: NSNotification) { 24 | 25 | if let userInfo = notification.userInfo { 26 | 27 | let startFrame = (userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue 28 | let endFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue 29 | let duration:TimeInterval = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0 30 | let animationCurveRawNSN = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber 31 | let animationCurveRaw = animationCurveRawNSN?.uintValue ?? UIView.AnimationOptions.curveEaseInOut.rawValue 32 | let animationCurve:UIView.AnimationOptions = UIView.AnimationOptions(rawValue: animationCurveRaw) 33 | 34 | if let startFrame = startFrame, let endFrame = endFrame { 35 | //print(startFrame.size.height) 36 | //print(endFrame.size.height) 37 | 38 | if startFrame.origin.y == endFrame.origin.y 39 | { 40 | return 41 | } 42 | 43 | if startFrame.origin.y > endFrame.origin.y //keyboard show 44 | { 45 | //tell Flutter the keyboard is showing and its height 46 | methodChannel?.invokeMethod("kbshow", arguments: endFrame.size.height) 47 | 48 | //this moves the keyboard frame down with the same animation curve as it is sliding up, effectively keeping it stationary just below the screen 49 | if UIApplication.shared.windows.count > 2, let kbWindow = UIApplication.shared.windows.last { 50 | UIView.animate(withDuration: duration, 51 | delay: TimeInterval(0.0), 52 | options: animationCurve, 53 | animations: { 54 | kbWindow.frame = CGRect.init(x: kbWindow.frame.minX, y: kbWindow.frame.minY + endFrame.size.height, width: kbWindow.frame.width, height: kbWindow.frame.height) 55 | }) 56 | } 57 | return; 58 | } 59 | else //keyboard hide 60 | { 61 | //tell Flutter the keyboard is hiding and its height 62 | methodChannel?.invokeMethod("kbhide", arguments: endFrame.size.height) 63 | 64 | //this moves the keyboard frame up with the same animation curve as it is sliding down, effectively keeping it stationary for about 0.4 seconds before it disappears 65 | if UIApplication.shared.windows.count > 2, let kbWindow = UIApplication.shared.windows.last { 66 | UIView.animate(withDuration: duration, 67 | delay: TimeInterval(0.0), 68 | options: animationCurve, 69 | animations: { 70 | kbWindow.subviews[0].subviews[0].frame = CGRect.init(x: kbWindow.subviews[0].subviews[0].frame.minX+0.0, y: kbWindow.subviews[0].subviews[0].frame.minY - startFrame.size.height, width: kbWindow.subviews[0].subviews[0].frame.width, height: kbWindow.subviews[0].subviews[0].frame.height) 71 | }) 72 | } 73 | return; 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /kbtestwidget.dart: -------------------------------------------------------------------------------- 1 | class KBTestWidget extends StatefulWidget { 2 | @override 3 | _KBTestWidgetState createState() => _KBTestWidgetState(); 4 | KBTestWidget({Key key, this.child}); 5 | final Widget child; 6 | } 7 | 8 | class _KBTestWidgetState extends State with SingleTickerProviderStateMixin { 9 | AnimationController _animationController; 10 | Animation _animation; 11 | var _kbHeight = 0.0; 12 | var _kbShown = false; 13 | final _methodChannel = const MethodChannel('kbtestwidget'); 14 | final _fps = 60; //TODO get actual hertz somewhere 15 | 16 | @override 17 | void initState() { 18 | super.initState(); 19 | _animationController = AnimationController( 20 | vsync: this, 21 | duration: const Duration(milliseconds: 400), 22 | reverseDuration: const Duration(milliseconds: 400), 23 | value: _kbHeight, 24 | ); 25 | _setAnimation(begin: 0.0, end: 1.0); 26 | 27 | _methodChannel.setMethodCallHandler(this._didRecieveNativeCall); 28 | } 29 | 30 | @override 31 | void dispose() { 32 | super.dispose(); 33 | _animationController.dispose(); 34 | } 35 | 36 | _setAnimation({double begin, double end}) { 37 | _animation = Tween(begin: begin, end: end).animate(CurvedAnimation( 38 | parent: _animationController, 39 | curve: Curves.easeOutCubic, 40 | reverseCurve: Curves.easeInCubic, 41 | )); 42 | } 43 | 44 | _startAnimation([bool forward = true]) { 45 | //animation starts at 0, but since it's already at 0 when it starts the first frame is just wasted time, so the value in from: makes it skips this while keeping the curve right 46 | final firstFrameSkip = 1000000 / (_fps * _animationController.duration.inMicroseconds); 47 | if (forward) { 48 | _animationController.forward(from: firstFrameSkip).whenComplete(() => _setAnimation(begin: 0.0, end: 1.0)); 49 | } else { 50 | _animationController.reverse(from: 1 - firstFrameSkip); 51 | } 52 | } 53 | 54 | Future _didRecieveNativeCall(MethodCall call) async { 55 | print(call); 56 | _kbHeight = call.arguments; 57 | 58 | if (call.method == 'kbshow') { 59 | _setAnimation(begin: 0.0, end: 1.0); 60 | _startAnimation(true); 61 | _kbShown = true; 62 | } else if (call.method == 'kbhide') { 63 | _startAnimation(false); 64 | _kbShown = false; 65 | FocusManager.instance.primaryFocus.unfocus(); //failsafe just in case the hide is not triggered by actual keyboard hiding 66 | } 67 | } 68 | 69 | @override 70 | Widget build(BuildContext context) { 71 | return GestureDetector( 72 | onPanUpdate: (details) { 73 | if (!_kbShown) return; 74 | 75 | final pos = min(MediaQuery.of(context).size.height - details.localPosition.dy, _kbHeight); 76 | setState(() { 77 | _setAnimation(begin: pos / _kbHeight, end: pos / _kbHeight); 78 | }); 79 | }, 80 | onPanEnd: (details) { 81 | if (!_kbShown) return; 82 | 83 | if (_animation.value > 0.5) { 84 | _animation = Tween(begin: _animation.value, end: 1.0).animate(CurvedAnimation( 85 | parent: _animationController, 86 | curve: Curves.bounceOut, 87 | reverseCurve: Curves.easeInCubic, 88 | )); 89 | _startAnimation(true); 90 | } else { 91 | _setAnimation(begin: 0.0, end: _animation.value); 92 | FocusManager.instance.primaryFocus.unfocus(); 93 | } 94 | }, 95 | child: Column( 96 | children: [ 97 | Expanded( 98 | child: widget.child, 99 | ), 100 | Material( 101 | color: Colors.blueGrey, //set the keyboard background color here 102 | child: AnimatedBuilder( 103 | animation: _animationController, 104 | builder: (_, child) { 105 | //after Flutter rendered the frame move the native keyboard, still the keyboard may be a frame ahead sometimes (in debug builds anyway), pretty weird 106 | WidgetsBinding.instance.addPostFrameCallback((_) { 107 | _methodChannel.invokeMethod('kbdown', _kbHeight + (_animation.value * _kbHeight) * -1); 108 | }); 109 | 110 | return SizedBox( 111 | width: double.infinity, 112 | height: _animation.value * _kbHeight, 113 | child: child, 114 | ); 115 | }, 116 | ), 117 | ) 118 | ], 119 | ), 120 | ); 121 | } 122 | } 123 | --------------------------------------------------------------------------------