diff --git a/android/src/main/jni/CMakeLists.txt b/android/src/main/jni/CMakeLists.txt index c12c264..dc7455f 100644 --- a/android/src/main/jni/CMakeLists.txt +++ b/android/src/main/jni/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.10) project(ux_keyboard) -add_library(ux_keyboard SHARED keyboard_bridge.c) +add_library(ux_keyboard SHARED keyboard_bridge.c sensor_bridge.c) target_link_libraries(ux_keyboard log) # 16KB page size support (required for Android 15+) diff --git a/android/src/main/jni/sensor_bridge.c b/android/src/main/jni/sensor_bridge.c new file mode 100644 index 0000000..8347b41 --- /dev/null +++ b/android/src/main/jni/sensor_bridge.c @@ -0,0 +1,18 @@ +#include +#include + +// Shared state — Kotlin writes, Dart reads via FFI. +// Encoded as Surface rotation convention: +// 0 = portraitUp, 1 = landscapeLeft, 2 = portraitDown, 3 = landscapeRight. +static int32_t g_device_orientation = 0; + +// --- Dart FFI reads --- + +int32_t ux_device_orientation(void) { return g_device_orientation; } + +// --- Kotlin JNI writes --- + +JNIEXPORT void JNICALL +Java_io_swipelab_ux_SensorBridge_nSetDeviceOrientation(JNIEnv *env, jclass cls, jint v) { + g_device_orientation = v; +} diff --git a/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt index 2d3c908..919c435 100644 --- a/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/KeyboardPlugin.kt @@ -7,12 +7,11 @@ import android.view.ViewTreeObserver 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 { +class KeyboardPlugin : NativePlugin, MethodChannel.MethodCallHandler { private var methodChannel: MethodChannel? = null private var activity: Activity? = null private var windowFocusListener: ViewTreeObserver.OnWindowFocusChangeListener? = null diff --git a/android/src/main/kotlin/io/swipelab/ux/NativePlugin.kt b/android/src/main/kotlin/io/swipelab/ux/NativePlugin.kt new file mode 100644 index 0000000..70aa1e4 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/NativePlugin.kt @@ -0,0 +1,13 @@ +package io.swipelab.ux + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +interface NativePlugin { + fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + fun onAttachedToActivity(binding: ActivityPluginBinding) {} + fun onDetachedFromActivity() {} + fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = onAttachedToActivity(binding) + fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity() +} diff --git a/android/src/main/kotlin/io/swipelab/ux/SensorBridge.kt b/android/src/main/kotlin/io/swipelab/ux/SensorBridge.kt new file mode 100644 index 0000000..4255dfb --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/SensorBridge.kt @@ -0,0 +1,9 @@ +package io.swipelab.ux + +object SensorBridge { + init { + System.loadLibrary("ux_keyboard") + } + + @JvmStatic external fun nSetDeviceOrientation(v: Int) +} diff --git a/android/src/main/kotlin/io/swipelab/ux/SensorPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/SensorPlugin.kt new file mode 100644 index 0000000..e117dc0 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/SensorPlugin.kt @@ -0,0 +1,55 @@ +package io.swipelab.ux + +import android.app.Activity +import android.view.OrientationEventListener +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +class SensorPlugin : NativePlugin { + private var listener: OrientationEventListener? = null + private var current: Int = 0 + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + attach(binding.activity) + } + + override fun onDetachedFromActivity() { + detach() + } + + private fun attach(activity: Activity) { + detach() + val l = object : OrientationEventListener(activity) { + override fun onOrientationChanged(degrees: Int) { + if (degrees == ORIENTATION_UNKNOWN) return + val bucket = bucketFor(degrees, current) + if (bucket == current) return + current = bucket + SensorBridge.nSetDeviceOrientation(bucket) + } + } + if (l.canDetectOrientation()) l.enable() + listener = l + } + + private fun detach() { + listener?.disable() + listener = null + } + + companion object { + internal fun bucketFor(degrees: Int, previous: Int): Int { + val d = ((degrees % 360) + 360) % 360 + return when { + d in 0..30 || d in 330..359 -> 0 + d in 60..120 -> 3 + d in 150..210 -> 2 + d in 240..300 -> 1 + d in 31..59 -> if (previous == 0 || previous == 3) previous else 0 + d in 121..149 -> if (previous == 3 || previous == 2) previous else 3 + d in 211..239 -> if (previous == 2 || previous == 1) previous else 2 + d in 301..329 -> if (previous == 1 || previous == 0) previous else 1 + else -> previous + } + } + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt new file mode 100644 index 0000000..3200cf2 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt @@ -0,0 +1,30 @@ +package io.swipelab.ux + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +class UxPlugin : FlutterPlugin, ActivityAware { + private val plugins: List = listOf( + KeyboardPlugin(), + SensorPlugin(), + ) + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) = + plugins.forEach { it.onAttachedToEngine(binding) } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) = + plugins.forEach { it.onDetachedFromEngine(binding) } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) = + plugins.forEach { it.onAttachedToActivity(binding) } + + override fun onDetachedFromActivity() = + plugins.forEach { it.onDetachedFromActivity() } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = + plugins.forEach { it.onReattachedToActivityForConfigChanges(binding) } + + override fun onDetachedFromActivityForConfigChanges() = + plugins.forEach { it.onDetachedFromActivityForConfigChanges() } +} diff --git a/ios/Classes/KeyboardPlugin.swift b/ios/Classes/KeyboardPlugin.swift index 982cf59..0e5dbcf 100644 --- a/ios/Classes/KeyboardPlugin.swift +++ b/ios/Classes/KeyboardPlugin.swift @@ -86,7 +86,7 @@ public func ux_keyboard_anim_gen() -> Int32 { // MARK: - Plugin -public class KeyboardPlugin: NSObject, FlutterPlugin { +public class KeyboardPlugin: NSObject, NativePlugin { fileprivate static var shared: KeyboardPlugin? fileprivate var wakeCallback: WakeCallback? @@ -112,15 +112,9 @@ public class KeyboardPlugin: NSObject, FlutterPlugin { fileprivate var animDuration: Double = 0 fileprivate var animGeneration: Int32 = 0 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = KeyboardPlugin() - KeyboardPlugin.shared = instance - - instance.startObserving() - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result(FlutterMethodNotImplemented) + public func register(with registrar: FlutterPluginRegistrar) { + KeyboardPlugin.shared = self + startObserving() } /// Wake Dart so it reads the height on its next frame diff --git a/ios/Classes/NativePlugin.swift b/ios/Classes/NativePlugin.swift new file mode 100644 index 0000000..3441162 --- /dev/null +++ b/ios/Classes/NativePlugin.swift @@ -0,0 +1,5 @@ +import Flutter + +public protocol NativePlugin { + func register(with registrar: FlutterPluginRegistrar) +} diff --git a/ios/Classes/SensorPlugin.swift b/ios/Classes/SensorPlugin.swift new file mode 100644 index 0000000..1d18b78 --- /dev/null +++ b/ios/Classes/SensorPlugin.swift @@ -0,0 +1,34 @@ +import CoreMotion +import Flutter +import UIKit + +@_cdecl("ux_device_orientation") +public func ux_device_orientation() -> Int32 { + return SensorPlugin.shared?.currentIndex ?? 0 +} + +public class SensorPlugin: NSObject, NativePlugin { + fileprivate static var shared: SensorPlugin? + fileprivate var currentIndex: Int32 = 0 + + private let motion = CMMotionManager() + + public func register(with registrar: FlutterPluginRegistrar) { + SensorPlugin.shared = self + guard motion.isDeviceMotionAvailable else { return } + motion.deviceMotionUpdateInterval = 1.0 / 10.0 + motion.startDeviceMotionUpdates(to: .main) { [weak self] data, _ in + guard let self = self, let g = data?.gravity else { return } + if abs(g.z) > 0.8 { return } + let next: Int32 + if abs(g.x) > abs(g.y) { + next = g.x < 0 ? 1 : 3 + } else { + next = g.y < 0 ? 0 : 2 + } + if next != self.currentIndex { + self.currentIndex = next + } + } + } +} diff --git a/ios/Classes/UxPlugin.swift b/ios/Classes/UxPlugin.swift new file mode 100644 index 0000000..239d9f6 --- /dev/null +++ b/ios/Classes/UxPlugin.swift @@ -0,0 +1,20 @@ +import Flutter +import UIKit + +public class UxPlugin: NSObject, FlutterPlugin { + private static var plugins: [NativePlugin] = [] + + public static func register(with registrar: FlutterPluginRegistrar) { + plugins = [ + KeyboardPlugin(), + SensorPlugin(), + ] + for plugin in plugins { + plugin.register(with: registrar) + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(FlutterMethodNotImplemented) + } +} diff --git a/lib/src/sensor.dart b/lib/src/sensor.dart new file mode 100644 index 0000000..ecf1b47 --- /dev/null +++ b/lib/src/sensor.dart @@ -0,0 +1,37 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +DynamicLibrary? _initLib() { + if (Platform.isIOS) return DynamicLibrary.process(); + if (Platform.isAndroid) return DynamicLibrary.open('libux_keyboard.so'); + return null; +} + +final DynamicLibrary? _lib = _initLib(); + +int Function()? _lookupInt32(String name) { + if (_lib == null) return null; + try { + return _lib!.lookup>(name).asFunction(); + } catch (_) { + return null; + } +} + +final _uxDeviceOrientation = _lookupInt32('ux_device_orientation'); + +class UxSensor { + UxSensor._(); + + /// Accelerometer-driven physical device rotation; updates regardless of + /// OS auto-rotate or app UI orientation lock. + static DeviceOrientation get orientation { + final idx = _uxDeviceOrientation?.call() ?? 0; + if (idx < 0 || idx >= DeviceOrientation.values.length) { + return DeviceOrientation.portraitUp; + } + return DeviceOrientation.values[idx]; + } +} diff --git a/lib/ux.dart b/lib/ux.dart index 5d3156d..87f6463 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -9,3 +9,4 @@ export 'src/bend_box.dart'; export 'src/json_extension.dart'; export 'src/bezier.dart'; export 'src/keyboard.dart'; +export 'src/sensor.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 7462fb8..b7d8f1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate keyboard height tracking via FFI with interactive dismiss, bezier utilities, and layout primitives. -version: 0.4.0 +version: 0.5.0 homepage: https://swipelab.co/ux.html repository: https://github.com/swipelab/ux issue_tracker: https://github.com/swipelab/ux/issues @@ -31,7 +31,7 @@ flutter: plugin: platforms: ios: - pluginClass: KeyboardPlugin + pluginClass: UxPlugin android: package: io.swipelab.ux - pluginClass: KeyboardPlugin + pluginClass: UxPlugin