diff --git a/android/build.gradle b/android/build.gradle index eb298b0..b3d4e2a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,33 +2,53 @@ group 'io.swipelab.ux' version '1.0-SNAPSHOT' buildscript { + ext.kotlin_version = '1.9.22' repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } rootProject.allprojects { repositories { google() - jcenter() + mavenCentral() } } apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { - compileSdkVersion 28 + namespace 'io.swipelab.ux' + compileSdk 34 defaultConfig { - minSdkVersion 16 - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + minSdk 21 + externalNativeBuild { + cmake { + cppFlags "" + } + } } - lintOptions { - disable 'InvalidPackage' + + externalNativeBuild { + cmake { + path "src/main/jni/CMakeLists.txt" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' } } diff --git a/android/src/main/java/io/swipelab/ux/UxPlugin.java b/android/src/main/java/io/swipelab/ux/UxPlugin.java deleted file mode 100644 index dd70a84..0000000 --- a/android/src/main/java/io/swipelab/ux/UxPlugin.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.swipelab.ux; - -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugin.common.PluginRegistry.Registrar; - -/** UxPlugin */ -public class UxPlugin implements MethodCallHandler { - /** Plugin registration. */ - public static void registerWith(Registrar registrar) { - final MethodChannel channel = new MethodChannel(registrar.messenger(), "ux"); - channel.setMethodCallHandler(new UxPlugin()); - } - - @Override - public void onMethodCall(MethodCall call, Result result) { - if (call.method.equals("getPlatformVersion")) { - result.success("Android " + android.os.Build.VERSION.RELEASE); - } else { - result.notImplemented(); - } - } -} diff --git a/android/src/main/jni/CMakeLists.txt b/android/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000..c12c264 --- /dev/null +++ b/android/src/main/jni/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.10) +project(ux_keyboard) + +add_library(ux_keyboard SHARED keyboard_bridge.c) +target_link_libraries(ux_keyboard log) + +# 16KB page size support (required for Android 15+) +target_link_options(ux_keyboard PRIVATE "-Wl,-z,max-page-size=16384") diff --git a/android/src/main/jni/keyboard_bridge.c b/android/src/main/jni/keyboard_bridge.c new file mode 100644 index 0000000..8ffb494 --- /dev/null +++ b/android/src/main/jni/keyboard_bridge.c @@ -0,0 +1,53 @@ +#include +#include + +// Shared state — Kotlin writes, Dart reads via FFI +static double g_keyboard_height = 0; +static double g_system_height = 0; +static int32_t g_is_tracking = 0; +static double g_anim_target = 0; +static double g_anim_duration = 0; +static int32_t g_anim_generation = 0; + +// --- Dart FFI reads (same signatures as iOS) --- + +double ux_keyboard_height(void) { return g_keyboard_height; } +double ux_system_keyboard_height(void) { return g_system_height; } +int32_t ux_is_tracking(void) { return g_is_tracking; } +double ux_keyboard_anim_target(void) { return g_anim_target; } +double ux_keyboard_anim_duration(void) { return g_anim_duration; } +int32_t ux_keyboard_anim_gen(void) { return g_anim_generation; } + +// --- Stubs for Dart FFI parity with iOS --- + +void ux_enable_interactive_dismiss(double inset) { (void)inset; } +void ux_disable_interactive_dismiss(void) {} + +// --- Kotlin JNI writes --- + +JNIEXPORT void JNICALL +Java_io_swipelab_ux_KeyboardBridge_nSetHeight(JNIEnv *env, jclass cls, jdouble h) { + g_keyboard_height = h; +} + +JNIEXPORT void JNICALL +Java_io_swipelab_ux_KeyboardBridge_nSetSystemHeight(JNIEnv *env, jclass cls, jdouble h) { + g_system_height = h; +} + +JNIEXPORT jdouble JNICALL +Java_io_swipelab_ux_KeyboardBridge_nGetSystemHeight(JNIEnv *env, jclass cls) { + return g_system_height; +} + +JNIEXPORT void JNICALL +Java_io_swipelab_ux_KeyboardBridge_nSetTracking(JNIEnv *env, jclass cls, jint v) { + g_is_tracking = v; +} + +JNIEXPORT void JNICALL +Java_io_swipelab_ux_KeyboardBridge_nSetAnim(JNIEnv *env, jclass cls, jdouble target, jdouble duration) { + g_anim_target = target; + g_anim_duration = duration; + g_anim_generation++; +} diff --git a/android/src/main/kotlin/io/swipelab/ux/KeyboardBridge.kt b/android/src/main/kotlin/io/swipelab/ux/KeyboardBridge.kt new file mode 100644 index 0000000..4623e4f --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/KeyboardBridge.kt @@ -0,0 +1,14 @@ +package io.swipelab.ux + +/// JNI bridge to the C globals that Dart reads via FFI. +object KeyboardBridge { + init { + System.loadLibrary("ux_keyboard") + } + + @JvmStatic external fun nSetHeight(h: Double) + @JvmStatic external fun nSetSystemHeight(h: Double) + @JvmStatic external fun nGetSystemHeight(): Double + @JvmStatic external fun nSetTracking(v: Int) + @JvmStatic external fun nSetAnim(target: Double, duration: Double) +} diff --git a/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt new file mode 100644 index 0000000..f251d46 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt @@ -0,0 +1,116 @@ +package io.swipelab.ux + +import android.app.Activity +import android.graphics.Insets +import android.os.Build +import android.view.WindowInsets +import android.view.WindowInsetsAnimation +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class KeyboardPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware { + private var methodChannel: MethodChannel? = null + private var activity: Activity? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel = MethodChannel(binding.binaryMessenger, "ux/keyboard").also { + it.setMethodCallHandler(this) + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel?.setMethodCallHandler(null) + methodChannel = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "enableInteractiveDismiss" -> result.success(null) + "disableInteractiveDismiss" -> result.success(null) + else -> result.notImplemented() + } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + setupInsetsCallback() + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + setupInsetsCallback() + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + private fun setupInsetsCallback() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val view = activity?.window?.decorView ?: return + + // Catch inset changes that don't trigger animations (e.g., emoji keyboard resize) + view.viewTreeObserver.addOnGlobalLayoutListener { + val insets = view.rootWindowInsets?.getInsets(WindowInsets.Type.ime()) ?: Insets.NONE + val density = view.resources.displayMetrics.density + val height = insets.bottom.toDouble() / density + + if (height != KeyboardBridge.nGetSystemHeight()) { + KeyboardBridge.nSetSystemHeight(height) + KeyboardBridge.nSetHeight(height) + } + } + + view.setWindowInsetsAnimationCallback(object : WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) { + override fun onPrepare(animation: WindowInsetsAnimation) {} + + override fun onStart( + animation: WindowInsetsAnimation, + bounds: WindowInsetsAnimation.Bounds + ): WindowInsetsAnimation.Bounds { + val insets = view.rootWindowInsets?.getInsets(WindowInsets.Type.ime()) ?: Insets.NONE + val density = view.resources.displayMetrics.density + val targetHeight = insets.bottom.toDouble() / density + + KeyboardBridge.nSetSystemHeight(targetHeight) + + val duration = animation.durationMillis / 1000.0 + KeyboardBridge.nSetAnim(targetHeight, duration) + + return bounds + } + + override fun onProgress( + insets: WindowInsets, + runningAnimations: MutableList + ): WindowInsets { + val imeInsets = insets.getInsets(WindowInsets.Type.ime()) + val density = view.resources.displayMetrics.density + val height = imeInsets.bottom.toDouble() / density + + KeyboardBridge.nSetHeight(height) + + return insets + } + + override fun onEnd(animation: WindowInsetsAnimation) { + val insets = view.rootWindowInsets?.getInsets(WindowInsets.Type.ime()) ?: Insets.NONE + val density = view.resources.displayMetrics.density + val height = insets.bottom.toDouble() / density + + KeyboardBridge.nSetHeight(height) + + if (height <= 0) { + KeyboardBridge.nSetSystemHeight(0.0) + } + } + }) + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 20f3ae8..78a3141 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,68 +1,102 @@ import 'package:flutter/material.dart'; -import 'dart:async'; - -import 'package:flutter/services.dart'; import 'package:ux/ux.dart'; -void main() => runApp(MyApp()); +void main() => runApp(MaterialApp(home: KeyboardExample())); -class MyApp extends StatefulWidget { +class KeyboardExample extends StatefulWidget { @override - _MyAppState createState() => _MyAppState(); + State createState() => _KeyboardExampleState(); } -class _MyAppState extends State { - String _platformVersion = 'Unknown'; +class _KeyboardExampleState extends State { + final _keyboard = UxKeyboard.instance; + final _focusNode = FocusNode(); @override void initState() { super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - try { - platformVersion = await UX.platformVersion; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - if (!mounted) return; - - setState(() { - _platformVersion = platformVersion; - }); + _keyboard.addListener(_onKeyboard); + _keyboard.enableInteractiveDismiss(trackingInset: 56); } + @override + void dispose() { + _keyboard.removeListener(_onKeyboard); + _keyboard.disableInteractiveDismiss(); + _focusNode.dispose(); + super.dispose(); + } + + void _onKeyboard() => setState(() {}); + @override Widget build(BuildContext context) { - return MaterialApp( - routes: { - '/': (context) => Scaffold( - appBar: AppBar( - title: const Text('Plugin example app'), - ), - body: Builder( - builder: (context) => ListView( - padding: EdgeInsets.only(top: 48), - children: [ - ListTile(title: Text('Running on: $_platformVersion\n')), - ListTile( - title: Text('Show a simple note'), - //onTap: () => context.showText('This is a simple note'), + final bottomInset = _keyboard.height; + final safeArea = MediaQuery.paddingOf(context).bottom; + final bottom = bottomInset > 0 ? bottomInset : safeArea; + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar(title: Text('UxKeyboard')), + body: Column( + children: [ + Expanded( + child: ListView.builder( + reverse: true, + padding: EdgeInsets.only(bottom: 60 + bottom, top: 16), + itemCount: 30, + itemBuilder: (context, i) => Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: i % 3 == 0 ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: i % 3 == 0 ? Colors.blue[100] : Colors.grey[200], + borderRadius: BorderRadius.circular(16), ), - ListTile( - title: Text('Show modal note'), - //onTap: () => context.showText('This is a modal note', backdropBlur: 6, modal: true), - ) - ], + child: Text('Message ${30 - i}'), + ), ), ), - ) - }, + ), + ), + Container( + padding: EdgeInsets.only( + left: 12, + right: 12, + top: 8, + bottom: 8 + bottom, + ), + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey[300]!)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + isDense: true, + ), + ), + ), + SizedBox(width: 8), + IconButton( + icon: Icon(Icons.send, color: Colors.blue), + onPressed: () {}, + ), + ], + ), + ), + ], + ), ); } } diff --git a/ios/Classes/UxPlugin.h b/ios/Classes/UxPlugin.h deleted file mode 100644 index c609615..0000000 --- a/ios/Classes/UxPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface UxPlugin : NSObject -@end diff --git a/ios/Classes/UxPlugin.m b/ios/Classes/UxPlugin.m deleted file mode 100644 index 6624b72..0000000 --- a/ios/Classes/UxPlugin.m +++ /dev/null @@ -1,20 +0,0 @@ -#import "UxPlugin.h" - -@implementation UxPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - FlutterMethodChannel* channel = [FlutterMethodChannel - methodChannelWithName:@"ux" - binaryMessenger:[registrar messenger]]; - UxPlugin* instance = [[UxPlugin alloc] init]; - [registrar addMethodCallDelegate:instance channel:channel]; -} - -- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - if ([@"getPlatformVersion" isEqualToString:call.method]) { - result([@"iOS " stringByAppendingString:[[UIDevice currentDevice] systemVersion]]); - } else { - result(FlutterMethodNotImplemented); - } -} - -@end diff --git a/ios/ux.podspec b/ios/ux.podspec index 9ac5f99..96f0e05 100644 --- a/ios/ux.podspec +++ b/ios/ux.podspec @@ -1,21 +1,13 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# Pod::Spec.new do |s| s.name = 'ux' - s.version = '0.0.1' - s.summary = 'A new flutter plugin project.' - s.description = <<-DESC -A new flutter plugin project. - DESC - s.homepage = 'http://example.com' + s.version = '0.2.0' + s.summary = 'UX Kit – Flutter plugin with keyboard tracking and interactive dismiss.' + s.homepage = 'https://swipelab.co/ux.html' s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } + s.author = { 'Swipelab' => 'hello@swipelab.co' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' + s.source_files = 'Classes/**/*.swift' s.dependency 'Flutter' - - s.ios.deployment_target = '8.0' + s.ios.deployment_target = '13.0' + s.swift_version = '5.0' end - diff --git a/lib/src/keyboard.dart b/lib/src/keyboard.dart index 73742f2..8cdac64 100644 --- a/lib/src/keyboard.dart +++ b/lib/src/keyboard.dart @@ -5,11 +5,10 @@ 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(); + if (Platform.isIOS) return DynamicLibrary.process(); + if (Platform.isAndroid) return DynamicLibrary.open('libux_keyboard.so'); + return null; } final DynamicLibrary? _lib = _initLib(); @@ -90,7 +89,7 @@ class _SampledCurve extends Curve { class UxKeyboard with ChangeNotifier { UxKeyboard._() { - if (!_isIOS) return; + if (_lib == null) return; SchedulerBinding.instance.addPersistentFrameCallback(_onFrame); } @@ -116,18 +115,22 @@ class UxKeyboard with ChangeNotifier { 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; + // On iOS, replay the animation in Dart with a head start (native only + // gives start/end via notification). On Android, WindowInsetsAnimation + // pushes per-frame values directly — no replay needed. + if (Platform.isIOS) { + 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; + } } } @@ -155,7 +158,8 @@ class UxKeyboard with ChangeNotifier { notifyListeners(); } - if (_isAnimating) { + // Keep scheduling frames while animating or keyboard is active + if (_isAnimating || (!Platform.isIOS && (h > 0 || _height > 0))) { SchedulerBinding.instance.scheduleFrame(); } } diff --git a/lib/ux.dart b/lib/ux.dart index c389904..7c35632 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -3,15 +3,4 @@ library ux; export 'src/bend_box.dart'; export 'src/json_extension.dart'; export 'src/bezier.dart'; - -import 'dart:async'; -import 'package:flutter/services.dart'; - -class UX { - static const MethodChannel _channel = const MethodChannel('ux'); - - static Future get platformVersion async { - final String version = await _channel.invokeMethod('getPlatformVersion'); - return version; - } -} \ No newline at end of file +export 'src/keyboard.dart'; \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 210e92c..148b853 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,5 +16,9 @@ dev_dependencies: flutter: plugin: - androidPackage: io.swipelab.ux - pluginClass: UxPlugin \ No newline at end of file + platforms: + ios: + pluginClass: KeyboardPlugin + android: + package: io.swipelab.ux + pluginClass: KeyboardPlugin \ No newline at end of file