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