android: keyboard height tracking via JNI/FFI bridge

- C bridge (keyboard_bridge.c) stores keyboard state in globals.
  Kotlin writes via JNI, Dart reads via FFI — zero async delay,
  same architecture as iOS.
- WindowInsetsAnimation.Callback tracks open/close per-frame.
- OnGlobalLayoutListener catches silent height changes (emoji
  keyboard resize, floating keyboard toggle).
- Dart animation replay stays iOS-only; Android reads native
  per-frame values directly.
- Cleaned up old Java stub, updated build.gradle for Kotlin + CMake
  with 16KB page alignment (Android 15+).
- Example app rewritten to demonstrate UxKeyboard usage.
This commit is contained in:
agra
2026-04-15 23:49:16 +03:00
parent a1ab667178
commit 0be198e388
13 changed files with 336 additions and 151 deletions

View File

@@ -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'
}
}

View File

@@ -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();
}
}
}

View File

@@ -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")

View File

@@ -0,0 +1,53 @@
#include <jni.h>
#include <stdint.h>
// 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++;
}

View File

@@ -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)
}

View File

@@ -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<WindowInsetsAnimation>
): 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)
}
}
})
}
}

View File

@@ -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<KeyboardExample> createState() => _KeyboardExampleState();
}
class _MyAppState extends State<MyApp> {
String _platformVersion = 'Unknown';
class _KeyboardExampleState extends State<KeyboardExample> {
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<void> 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: () {},
),
],
),
),
],
),
);
}
}

View File

@@ -1,4 +0,0 @@
#import <Flutter/Flutter.h>
@interface UxPlugin : NSObject<FlutterPlugin>
@end

View File

@@ -1,20 +0,0 @@
#import "UxPlugin.h"
@implementation UxPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)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

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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<String> get platformVersion async {
final String version = await _channel.invokeMethod('getPlatformVersion');
return version;
}
}
export 'src/keyboard.dart';

View File

@@ -16,5 +16,9 @@ dev_dependencies:
flutter:
plugin:
androidPackage: io.swipelab.ux
pluginClass: UxPlugin
platforms:
ios:
pluginClass: KeyboardPlugin
android:
package: io.swipelab.ux
pluginClass: KeyboardPlugin