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:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
android/src/main/jni/CMakeLists.txt
Normal file
8
android/src/main/jni/CMakeLists.txt
Normal 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")
|
||||
53
android/src/main/jni/keyboard_bridge.c
Normal file
53
android/src/main/jni/keyboard_bridge.c
Normal 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++;
|
||||
}
|
||||
14
android/src/main/kotlin/io/swipelab/ux/KeyboardBridge.kt
Normal file
14
android/src/main/kotlin/io/swipelab/ux/KeyboardBridge.kt
Normal 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)
|
||||
}
|
||||
116
android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt
Normal file
116
android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user