keyboard: fix interactive dismiss race conditions, rewrite example

Fix several race conditions in the iOS interactive dismiss flow:
- Don't skip keyboard close notifications during pan tracking, which
  left Dart unaware the keyboard was dismissed
- Guard resignFirstResponder with generation check so reopened keyboards
  aren't killed by stale dismiss completions
- Block pan tracking from starting during an in-flight dismiss animation
- Always reset keyboard view bounds when the keyboard opens, not just
  when the dismiss animator is still running
- Handle duration=0 keyboard notifications by snapping immediately
- Gate adaptive learning debug output behind kDebugMode

Rewrite the example as a chat UI demonstrating ListenableBuilder,
scroll freeze during interactive dismiss, and proper bottom inset
handling with max(keyboardHeight, safeBottom).

Modernize example Android project from v1 to v2 embedding with
current AGP/Gradle versions.
This commit is contained in:
agra
2026-04-16 18:34:05 +03:00
parent 0be198e388
commit 8ac7b5a5d5
22 changed files with 591 additions and 327 deletions

View File

@@ -1 +1 @@
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/swipelab/ux/","native_build":true,"dependencies":[]}],"android":[{"name":"ux","path":"/Users/agra/swipelab/ux/","native_build":true,"dependencies":[]}],"macos":[],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2025-02-02 15:02:54.184568","version":"3.24.5","swift_package_manager_enabled":false} {"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":true}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":true}],"macos":[],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-15 13:13:36.363037","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}

View File

@@ -4,7 +4,27 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: 20e59316b8b8474554b38493b8ca888794b0234a revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa"
channel: v1.7.8-hotfixes channel: "[user-branch]"
project_type: app project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
- platform: macos
create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -1,3 +1,8 @@
plugins {
id "com.android.application"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@@ -6,11 +11,6 @@ if (localPropertiesFile.exists()) {
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@@ -21,41 +21,30 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
compileSdkVersion 28 namespace "io.swipelab.ux_example"
compileSdk 35
lintOptions {
disable 'InvalidPackage'
}
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.swipelab.ux_example" applicationId "io.swipelab.ux_example"
minSdkVersion 16 minSdkVersion flutter.minSdkVersion
targetSdkVersion 28 targetSdk 35
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
} }
} }
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
} }
flutter { flutter {
source '../..' source '../..'
} }
dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

View File

@@ -1,33 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.swipelab.ux_example"> package="io.swipelab.ux_example">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
In most cases you can leave this as-is, but you if you want to provide
additional functionality it is fine to subclass or reimplement
FlutterApplication and put your custom class here. -->
<application <application
android:name="io.flutter.app.FlutterApplication"
android:label="ux_example" android:label="ux_example"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- This keeps the window background of the activity showing
until Flutter renders its first frame. It can be removed if
there is no splash screen (such as the default splash screen
defined in @style/LaunchTheme). -->
<meta-data <meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame" android:name="io.flutter.embedding.android.NormalTheme"
android:value="true" /> android:resource="@style/NormalTheme" />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application> </application>
</manifest> </manifest>

View File

@@ -1,13 +1,6 @@
package io.swipelab.ux_example; package io.swipelab.ux_example;
import android.os.Bundle; import io.flutter.embedding.android.FlutterActivity;
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;
public class MainActivity extends FlutterActivity { public class MainActivity extends FlutterActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);
}
} }

View File

@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources> </resources>

View File

@@ -1,18 +1,7 @@
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
}
}
allprojects { allprojects {
repositories { repositories {
google() google()
jcenter() mavenCentral()
} }
} }
@@ -24,6 +13,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.layout.buildDirectory
} }

View File

@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableR8=true android.enableJetifier=true

View File

@@ -1,6 +1,5 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -1,15 +1,24 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def plugins = new Properties() repositories {
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') google()
if (pluginsFile.exists()) { mavenCentral()
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } gradlePluginPortal()
}
} }
plugins.each { name, path -> plugins {
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() id "dev.flutter.flutter-plugin-loader" version "1.0.0"
include ":$name" id "com.android.application" version "8.7.0" apply false
project(":$name").projectDir = pluginDirectory
} }
include ":app"

View File

@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>8.0</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
# platform :ios, '9.0' # platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -10,63 +10,31 @@ project 'Runner', {
'Release' => :release, 'Release' => :release,
} }
def parse_KV_file(file, separator='=') def flutter_root
file_abs_path = File.expand_path(file) generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
if !File.exists? file_abs_path unless File.exist?(generated_xcode_build_settings_path)
return []; raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end end
pods_ary = []
skip_line_start_symbols = ["#", "/"] File.foreach(generated_xcode_build_settings_path) do |line|
File.foreach(file_abs_path) { |line| matches = line.match(/FLUTTER_ROOT\=(.*)/)
next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } return matches[1].strip if matches
plugin = line.split(pattern=separator) end
if plugin.length == 2 raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
podname = plugin[0].strip()
path = plugin[1].strip()
podpath = File.expand_path("#{path}", file_abs_path)
pods_ary.push({:name => podname, :path => podpath});
else
puts "Invalid plugin specification: #{line}"
end
}
return pods_ary
end end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do target 'Runner' do
# Prepare symlinks folder. We use symlinks to avoid having Podfile.lock use_frameworks!
# referring to absolute paths on developers' machines.
system('rm -rf .symlinks')
system('mkdir -p .symlinks/plugins')
# Flutter Pods flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig')
if generated_xcode_build_settings.empty?
puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first."
end
generated_xcode_build_settings.map { |p|
if p[:name] == 'FLUTTER_FRAMEWORK_DIR'
symlink = File.join('.symlinks', 'flutter')
File.symlink(File.dirname(p[:path]), symlink)
pod 'Flutter', :path => File.join(symlink, File.basename(p[:path]))
end
}
# Plugin Pods
plugin_pods = parse_KV_file('../.flutter-plugins')
plugin_pods.map { |p|
symlink = File.join('.symlinks', 'plugins', p[:name])
File.symlink(p[:path], symlink)
pod p[:name], :path => File.join(symlink, 'ios')
}
end end
# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system.
install! 'cocoapods', :disable_input_output_paths => true
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| flutter_additional_ios_build_settings(target)
config.build_settings['ENABLE_BITCODE'] = 'NO'
end
end end
end end

View File

@@ -3,16 +3,13 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 46; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; 792C661F9A831B1C3B10515D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 64E4EC7AC65146133031B4A4 /* Pods_Runner.framework */; };
3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; };
9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; };
978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; };
97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; };
@@ -28,8 +25,6 @@
dstPath = ""; dstPath = "";
dstSubfolderSpec = 10; dstSubfolderSpec = 10;
files = ( files = (
3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */,
9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */,
); );
name = "Embed Frameworks"; name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -37,16 +32,18 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
08531D61D302A2CE3B1ECC97 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = "<group>"; }; 64E4EC7AC65146133031B4A4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
73BC09F2143C24CC1154883A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
9676BCBFAD691B66977C17D8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
@@ -60,20 +57,35 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, 792C661F9A831B1C3B10515D /* Pods_Runner.framework in Frameworks */,
3B80C3941E831B6300D905FE /* App.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
3A74E1285E4DE31DB2DFB4A0 /* Pods */ = {
isa = PBXGroup;
children = (
73BC09F2143C24CC1154883A /* Pods-Runner.debug.xcconfig */,
08531D61D302A2CE3B1ECC97 /* Pods-Runner.release.xcconfig */,
9676BCBFAD691B66977C17D8 /* Pods-Runner.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
60EA09CFAEE451B185B2E653 /* Frameworks */ = {
isa = PBXGroup;
children = (
64E4EC7AC65146133031B4A4 /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3B80C3931E831B6300D905FE /* App.framework */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEBA1CF902C7004384FC /* Flutter.framework */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */,
@@ -87,7 +99,8 @@
9740EEB11CF90186004384FC /* Flutter */, 9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, 3A74E1285E4DE31DB2DFB4A0 /* Pods */,
60EA09CFAEE451B185B2E653 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -130,12 +143,14 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
309E2F35ADAAFD20A04DD6E4 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
AE20327F75A2E81CD74E3319 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -152,7 +167,7 @@
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastUpgradeCheck = 1020; LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "The Chromium Authors"; ORGANIZATIONNAME = "The Chromium Authors";
TargetAttributes = { TargetAttributes = {
97C146ED1CF9000F007C117D = { 97C146ED1CF9000F007C117D = {
@@ -194,22 +209,47 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 309E2F35ADAAFD20A04DD6E4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputFileListPaths = (
);
inputPaths = ( inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
); );
name = "Thin Binary"; name = "Thin Binary";
outputPaths = ( outputPaths = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
@@ -222,6 +262,24 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
}; };
AE20327F75A2E81CD74E3319 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh",
"${BUILT_PRODUCTS_DIR}/ux/ux.framework",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ux.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -259,7 +317,6 @@
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = { 249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
@@ -299,7 +356,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -313,14 +370,17 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = S8QB4VV633; DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = ( LIBRARY_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
@@ -333,7 +393,6 @@
}; };
97C147031CF9000F007C117D /* Debug */ = { 97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
@@ -379,7 +438,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -389,7 +448,6 @@
}; };
97C147041CF9000F007C117D /* Release */ = { 97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
@@ -429,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -449,7 +507,10 @@
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = ( LIBRARY_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
@@ -472,7 +533,10 @@
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = ( LIBRARY_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",

View File

@@ -2,6 +2,6 @@
<Workspace <Workspace
version = "1.0"> version = "1.0">
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "self:">
</FileRef> </FileRef>
</Workspace> </Workspace>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1020" LastUpgradeVersion = "1510"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -26,6 +26,7 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<Testables> <Testables>
</Testables> </Testables>
@@ -45,11 +46,13 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>

View File

@@ -41,5 +41,9 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -1,101 +1,156 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:ux/ux.dart'; import 'package:ux/ux.dart';
void main() => runApp(MaterialApp(home: KeyboardExample())); void main() => runApp(MaterialApp(
theme: ThemeData(useMaterial3: true),
home: ChatScreen(),
));
/// Demonstrates UxKeyboard in a chat UI:
/// - Frame-accurate keyboard height tracking (no Flutter viewInsets lag)
/// - Interactive dismiss (swipe the keyboard down like iMessage)
/// - Scroll freeze while the user is panning the keyboard
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
class KeyboardExample extends StatefulWidget {
@override @override
State<KeyboardExample> createState() => _KeyboardExampleState(); State<ChatScreen> createState() => _ChatScreenState();
} }
class _KeyboardExampleState extends State<KeyboardExample> { class _ChatScreenState extends State<ChatScreen> {
final _keyboard = UxKeyboard.instance; final _keyboard = UxKeyboard.instance;
final _focusNode = FocusNode(); final _textController = TextEditingController();
final _messages = List.generate(30, (i) => 'Message ${i + 1}');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_keyboard.addListener(_onKeyboard); // trackingInset: height of the input bar, so the pan-to-dismiss gesture
// activates when the finger enters the keyboard zone below the input bar.
_keyboard.enableInteractiveDismiss(trackingInset: 56); _keyboard.enableInteractiveDismiss(trackingInset: 56);
} }
@override @override
void dispose() { void dispose() {
_keyboard.removeListener(_onKeyboard);
_keyboard.disableInteractiveDismiss(); _keyboard.disableInteractiveDismiss();
_focusNode.dispose(); _textController.dispose();
super.dispose(); super.dispose();
} }
void _onKeyboard() => setState(() {}); void _send() {
final text = _textController.text.trim();
if (text.isEmpty) return;
setState(() => _messages.add(text));
_textController.clear();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = _keyboard.height; // Disable Flutter's built-in resize — we handle it ourselves.
final safeArea = MediaQuery.paddingOf(context).bottom;
final bottom = bottomInset > 0 ? bottomInset : safeArea;
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: AppBar(title: Text('UxKeyboard')), appBar: AppBar(title: Text('UxKeyboard Chat')),
body: Column( // ListenableBuilder rebuilds only when UxKeyboard notifies (height changes).
children: [ body: ListenableBuilder(
Expanded( listenable: _keyboard,
child: ListView.builder( builder: (context, _) {
reverse: true, final keyboardHeight = _keyboard.height;
padding: EdgeInsets.only(bottom: 60 + bottom, top: 16), final safeBottom = MediaQuery.viewPaddingOf(context).bottom;
itemCount: 30, final bottom = math.max(keyboardHeight, safeBottom);
itemBuilder: (context, i) => Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), return Column(
child: Align( children: [
alignment: i % 3 == 0 ? Alignment.centerRight : Alignment.centerLeft, Expanded(
child: Container( child: GestureDetector(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), onTap: () => FocusScope.of(context).unfocus(),
decoration: BoxDecoration( child: ListView.builder(
color: i % 3 == 0 ? Colors.blue[100] : Colors.grey[200], // Freeze scrolling while the user is panning the keyboard down.
borderRadius: BorderRadius.circular(16), // This prevents the list from bouncing during interactive dismiss.
), reverse: true,
child: Text('Message ${30 - i}'), physics: _keyboard.isTracking
? NeverScrollableScrollPhysics()
: null,
padding: EdgeInsets.only(top: 16, bottom: 8),
itemCount: _messages.length,
itemBuilder: (context, i) {
final isMe = i % 3 == 0;
return Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Align(
alignment: isMe
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
),
padding: EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Text(_messages[i]),
),
),
);
},
), ),
), ),
), ),
), // Input bar — sits directly above the keyboard.
), Container(
Container( padding: EdgeInsets.only(
padding: EdgeInsets.only( left: 12,
left: 12, right: 4,
right: 12, top: 8,
top: 8, bottom: 8 + bottom,
bottom: 8 + bottom, ),
), decoration: BoxDecoration(
decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface,
color: Colors.white, border: Border(
border: Border(top: BorderSide(color: Colors.grey[300]!)), top: BorderSide(
), color: Theme.of(context).colorScheme.outlineVariant,
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), child: Row(
IconButton( children: [
icon: Icon(Icons.send, color: Colors.blue), Expanded(
onPressed: () {}, child: TextField(
controller: _textController,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
isDense: true,
),
),
),
SizedBox(width: 4),
IconButton(
icon: Icon(Icons.send),
color: Theme.of(context).colorScheme.primary,
onPressed: _send,
),
],
), ),
], ),
), ],
), );
], },
), ),
); );
} }

View File

@@ -21,26 +21,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
collection: collection:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.1"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -53,10 +53,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.3"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -71,63 +71,63 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.5" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.5" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16+1" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.15.0" version: "1.17.0"
path: path:
dependency: transitive dependency: transitive
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -140,18 +140,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.12.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.4"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -172,25 +172,25 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.10"
ux: ux:
dependency: "direct dev" dependency: "direct dev"
description: description:
path: ".." path: ".."
relative: true relative: true
source: path source: path
version: "0.1.1" version: "0.2.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -200,5 +200,5 @@ packages:
source: hosted source: hosted
version: "14.2.5" version: "14.2.5"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -104,6 +104,7 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
// Pan gesture // Pan gesture
private var keyboardOriginY: CGFloat = 0 private var keyboardOriginY: CGFloat = 0
fileprivate var snapBackAnimator: UIViewPropertyAnimator? fileprivate var snapBackAnimator: UIViewPropertyAnimator?
private var dismissAnimator: UIViewPropertyAnimator?
private var trackingInset: CGFloat = 0 private var trackingInset: CGFloat = 0
// Animation params passed to Dart so it can replay the same animation // Animation params passed to Dart so it can replay the same animation
@@ -147,13 +148,22 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
} }
@objc private func keyboardWillChange(_ notification: Notification) { @objc private func keyboardWillChange(_ notification: Notification) {
guard !isTracking else { return }
guard let userInfo = notification.userInfo else { return } guard let userInfo = notification.userInfo else { return }
let endFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero let endFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero
let screenHeight = UIScreen.main.bounds.height let screenHeight = UIScreen.main.bounds.height
let height = max(0, screenHeight - endFrame.origin.y) let height = max(0, screenHeight - endFrame.origin.y)
// If the keyboard is closing while we're tracking (e.g. resignFirstResponder
// fired from a completed dismiss during a new pan), stop tracking and process
// the close otherwise Dart never learns the keyboard went away.
if isTracking {
if height == 0 {
isTracking = false
} else {
return
}
}
// Pass animation params to Dart so it can replay the same animation // Pass animation params to Dart so it can replay the same animation
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
animTarget = height animTarget = height
@@ -161,6 +171,16 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
animGeneration &+= 1 animGeneration &+= 1
if height > 0 { if height > 0 {
// Cancel any in-flight dismiss so it doesn't resignFirstResponder
// after the user has already re-focused.
if let anim = dismissAnimator {
anim.stopAnimation(true)
dismissAnimator = nil
}
// Always reset bounds the dismiss animation (or its completion)
// may have shifted the keyboard view offscreen.
isDismissing = false
keyboardView?.layer.bounds.origin = .zero
keyboardFullHeight = height keyboardFullHeight = height
} else { } else {
keyboardFullHeight = 0 keyboardFullHeight = 0
@@ -291,6 +311,8 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
case .changed: case .changed:
if !isTracking { if !isTracking {
// Don't start tracking during a dismiss the keyboard is going away.
guard dismissAnimator == nil, !isDismissing else { return }
// Use system keyboard height as source of truth // Use system keyboard height as source of truth
guard keyboardFullHeight > 0, keyboardView != nil else { return } guard keyboardFullHeight > 0, keyboardView != nil else { return }
@@ -352,6 +374,10 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
animDuration = 0.25 animDuration = 0.25
animGeneration &+= 1 animGeneration &+= 1
// Snapshot the generation so the completion can detect if the keyboard
// was reopened between now and when the animator finishes.
let genAtDismiss = animGeneration
let animator = UIViewPropertyAnimator(duration: 0.25, dampingRatio: 0.9) { [weak self] in let animator = UIViewPropertyAnimator(duration: 0.25, dampingRatio: 0.9) { [weak self] in
guard let kbView = self?.keyboardView else { return } guard let kbView = self?.keyboardView else { return }
kbView.layer.bounds = CGRect( kbView.layer.bounds = CGRect(
@@ -360,9 +386,14 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
) )
} }
animator.addCompletion { [weak self] _ in animator.addCompletion { [weak self] _ in
self?.dismissAnimator = nil
self?.isDismissing = false self?.isDismissing = false
// Only resign if no new keyboard event arrived since the dismiss started.
// Otherwise we'd kill a keyboard the user just re-opened.
guard self?.animGeneration == genAtDismiss else { return }
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
} }
dismissAnimator = animator
animator.startAnimation() animator.startAnimation()
} }

View File

@@ -1,5 +1,6 @@
import 'dart:ffi'; import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/animation.dart'; import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -63,7 +64,7 @@ final _uxDisableInteractiveDismiss = _lookupVoid('ux_disable_interactive_dismiss
/// iOS keyboard animation curve — sampled from native CADisplayLink. /// iOS keyboard animation curve — sampled from native CADisplayLink.
/// 21 points at t = 0.00, 0.05, ..., 1.00. Averaged from multiple open/close cycles. /// 21 points at t = 0.00, 0.05, ..., 1.00. Averaged from multiple open/close cycles.
const _kKeyboardSamples = <double>[ const _kIOSKeyboardSamples = <double>[
0.0000, 0.0618, 0.1991, 0.3618, 0.5123, // t=0.00..0.20 0.0000, 0.0618, 0.1991, 0.3618, 0.5123, // t=0.00..0.20
0.6375, 0.7362, 0.8112, 0.8664, 0.9062, // t=0.25..0.45 0.6375, 0.7362, 0.8112, 0.8664, 0.9062, // t=0.25..0.45
0.9347, 0.9550, 0.9692, 0.9790, 0.9858, // t=0.50..0.70 0.9347, 0.9550, 0.9692, 0.9790, 0.9858, // t=0.50..0.70
@@ -71,22 +72,60 @@ const _kKeyboardSamples = <double>[
0.9993, // t=1.00 0.9993, // t=1.00
]; ];
const Curve _kKeyboardCurve = _SampledCurve(_kKeyboardSamples); /// Android keyboard animation curve — sampled from WindowInsetsAnimation.onProgress.
/// 21 points at t = 0.00, 0.05, ..., 1.00. Averaged from multiple open/close cycles.
const _kAndroidKeyboardSamples = <double>[
0.0000, 0.0056, 0.0702, 0.2332, 0.4147, // t=0.00..0.20
0.5414, 0.6413, 0.7130, 0.7722, 0.8181, // t=0.25..0.45
0.8576, 0.8885, 0.9146, 0.9372, 0.9538, // t=0.50..0.70
0.9675, 0.9788, 0.9882, 0.9930, 0.9974, // t=0.75..0.95
1.0000, // t=1.00
];
class _SampledCurve extends Curve { /// Default head start — adaptive learning refines this from observations.
const _SampledCurve(this._samples); const _kDefaultHeadStart = 0.016;
final List<double> _samples;
@override /// EMA blending factor for adaptive updates.
double transformInternal(double t) { const _kAdaptAlpha = 0.3;
final n = _samples.length - 1;
final scaled = t * n; /// Maximum error (in normalized progress) before the LUT is considered stale.
final i = scaled.floor().clamp(0, n - 1); const _kAdaptThreshold = 0.02;
final frac = scaled - i;
return _samples[i] + frac * (_samples[i + 1] - _samples[i]); /// Number of clean animations required before updating.
} const _kMinLearnCount = 2;
/// Number of LUT sample points (t = 0.00, 0.05, ..., 1.00).
const _kSampleCount = 21;
// ---------------------------------------------------------------------------
/// Linear interpolation through a list of evenly-spaced samples.
double _lerpSamples(List<double> samples, double t) {
final n = samples.length - 1;
final scaled = t * n;
final i = scaled.floor().clamp(0, n - 1);
final frac = scaled - i;
return samples[i] + frac * (samples[i + 1] - samples[i]);
} }
/// Inverse lookup: find the `t` where `_lerpSamples(samples, t) ≈ value`.
/// Assumes samples are monotonically increasing.
double _inverseLerp(List<double> samples, double value) {
final n = samples.length - 1;
if (value <= samples.first) return 0;
if (value >= samples.last) return 1;
for (int i = 0; i < n; i++) {
if (value <= samples[i + 1]) {
final span = samples[i + 1] - samples[i];
final frac = span > 0 ? (value - samples[i]) / span : 0.0;
return (i + frac) / n;
}
}
return 1;
}
// ---------------------------------------------------------------------------
class UxKeyboard with ChangeNotifier { class UxKeyboard with ChangeNotifier {
UxKeyboard._() { UxKeyboard._() {
if (_lib == null) return; if (_lib == null) return;
@@ -110,46 +149,68 @@ class UxKeyboard with ChangeNotifier {
double _animStartTime = 0; // seconds, from frame timestamp double _animStartTime = 0; // seconds, from frame timestamp
bool _isAnimating = false; bool _isAnimating = false;
// Adaptive LUT state.
late final List<double> _samples = List.of(
Platform.isIOS ? _kIOSKeyboardSamples : _kAndroidKeyboardSamples);
double _headStart = _kDefaultHeadStart;
int _learnCount = 0;
bool _converged = false;
final List<({double t, double p})> _obs = [];
void _onFrame(Duration timestamp) { void _onFrame(Duration timestamp) {
if (_uxKeyboardHeight == null) return; if (_uxKeyboardHeight == null) return;
final ts = timestamp.inMicroseconds / Duration.microsecondsPerSecond; final ts = timestamp.inMicroseconds / Duration.microsecondsPerSecond;
// On iOS, replay the animation in Dart with a head start (native only // Detect new animation generation (both platforms).
// gives start/end via notification). On Android, WindowInsetsAnimation final gen = _uxAnimGen?.call() ?? 0;
// pushes per-frame values directly — no replay needed. if (gen != _lastAnimGen) {
if (Platform.isIOS) { _lastAnimGen = gen;
final gen = _uxAnimGen?.call() ?? 0; final target = _uxAnimTarget?.call() ?? 0;
if (gen != _lastAnimGen) { final duration = _uxAnimDuration?.call() ?? 0;
_lastAnimGen = gen; if (duration > 0) {
final target = _uxAnimTarget?.call() ?? 0; _obs.clear(); // discard interrupted observations
final duration = _uxAnimDuration?.call() ?? 0; _animFrom = _height;
if (duration > 0) { _animTo = target;
_animFrom = _height; _animDuration = duration;
_animTo = target; _animStartTime = ts - _headStart;
_animDuration = duration - 0.01; // finish 10ms ahead of native _isAnimating = true;
_animStartTime = ts - 0.016; // compensate 2-frame pipeline delay } else {
_isAnimating = true; // Instant change (duration == 0) — snap immediately.
} _isAnimating = false;
_height = target;
notifyListeners();
} }
} }
// Abort animation if interactive tracking started // Abort animation if interactive tracking started.
if (_isAnimating && (_uxIsTracking?.call() ?? 0) > 0) { if (_isAnimating && (_uxIsTracking?.call() ?? 0) > 0) {
_isAnimating = false; _isAnimating = false;
_obs.clear();
} }
double h; double h;
if (_isAnimating) { if (_isAnimating) {
final elapsed = ts - _animStartTime; final elapsed = ts - _animStartTime;
final t = (elapsed / _animDuration).clamp(0.0, 1.0); final t = (elapsed / _animDuration).clamp(0.0, 1.0);
h = _animFrom + (_animTo - _animFrom) * _kKeyboardCurve.transform(t); h = _animFrom + (_animTo - _animFrom) * _lerpSamples(_samples, t);
// Collect observations for adaptive learning.
if (t < 1.0 && !_converged) {
final ffi = _uxKeyboardHeight!();
final range = _animTo - _animFrom;
if (range.abs() > 1) {
final p = ((ffi - _animFrom) / range).clamp(0.0, 1.0);
_obs.add((t: t, p: p));
}
}
if (t >= 1.0) { if (t >= 1.0) {
_isAnimating = false;
h = _animTo; h = _animTo;
if (!Platform.isIOS) _isAnimating = false;
_finishLearning();
} }
} else { } else {
// Fallback: read FFI directly (interactive dismiss, snap-back, etc.)
h = _uxKeyboardHeight!(); h = _uxKeyboardHeight!();
} }
@@ -158,12 +219,95 @@ class UxKeyboard with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// Keep scheduling frames while animating or keyboard is active // Schedule frames while the curve is still running.
if (_isAnimating || (!Platform.isIOS && (h > 0 || _height > 0))) { final curveActive = _isAnimating &&
(ts - _animStartTime) < _animDuration;
if (curveActive || (!Platform.isIOS && (h > 0 || _height > 0))) {
SchedulerBinding.instance.scheduleFrame(); SchedulerBinding.instance.scheduleFrame();
} }
} }
/// After a clean animation, learn the head start and curve shape.
void _finishLearning() {
if (_converged || _obs.length < 10) {
_obs.clear();
return;
}
_learnCount++;
if (_learnCount < _kMinLearnCount) {
_obs.clear();
return;
}
// Step 1: measure head start (δ) from the steep middle of the curve.
final lags = <double>[];
for (final o in _obs) {
if (o.p < 0.15 || o.p > 0.85) continue;
final lutT = _inverseLerp(_samples, o.p);
lags.add(o.t - lutT);
}
if (lags.length >= 3) {
lags.sort();
final medianLag = lags[lags.length ~/ 2];
final measuredHeadStart = medianLag * _animDuration;
_headStart += _kAdaptAlpha * (measuredHeadStart - _headStart);
_headStart = _headStart.clamp(0.0, 0.050); // sanity: 050ms
}
// Step 2: update curve shape — shift observations by δ, resample to grid.
final delta = _headStart / _animDuration;
final observed = List<double>.filled(_kSampleCount, -1);
// Interpolate shifted observations onto the 21-point grid.
final shifted = _obs
.map((o) => (t: o.t - delta, p: o.p))
.where((o) => o.t >= 0 && o.t <= 0.95)
.toList()
..sort((a, b) => a.t.compareTo(b.t));
if (shifted.length >= 5) {
final step = 1.0 / (_kSampleCount - 1); // 0.05
for (int i = 0; i < _kSampleCount; i++) {
final gridT = i * step;
if (gridT > 0.95) break;
// Find bracketing observations.
int lo = 0;
while (lo < shifted.length - 1 && shifted[lo + 1].t <= gridT) {
lo++;
}
if (lo >= shifted.length - 1) continue;
final a = shifted[lo], b = shifted[lo + 1];
final span = b.t - a.t;
if (span < 0.001) continue;
final frac = (gridT - a.t) / span;
observed[i] = a.p + frac * (b.p - a.p);
}
// EMA blend where we have valid observations.
double maxError = 0;
for (int i = 1; i < _kSampleCount - 1; i++) {
if (observed[i] < 0) continue;
final error = (observed[i] - _samples[i]).abs();
maxError = math.max(maxError, error);
_samples[i] += _kAdaptAlpha * (observed[i] - _samples[i]);
}
// Endpoints stay pinned.
_samples[0] = 0;
_samples[_kSampleCount - 1] = _samples[_kSampleCount - 1]
.clamp(0.99, 1.0);
_converged = maxError < _kAdaptThreshold;
if (kDebugMode) {
print('[KB] adapt #$_learnCount headStart=${(_headStart * 1000).toStringAsFixed(1)}ms '
'maxErr=${(maxError * 100).toStringAsFixed(1)}% '
'${_converged ? "CONVERGED" : "learning"}');
}
}
_obs.clear();
}
void enableInteractiveDismiss({double trackingInset = 0}) => _uxEnableInteractiveDismiss?.call(trackingInset); void enableInteractiveDismiss({double trackingInset = 0}) => _uxEnableInteractiveDismiss?.call(trackingInset);
void disableInteractiveDismiss() => _uxDisableInteractiveDismiss?.call(); void disableInteractiveDismiss() => _uxDisableInteractiveDismiss?.call();
} }

View File

@@ -21,34 +21,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
collection: collection:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.3"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -63,63 +63,63 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.5" version: "11.0.2"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.5" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16+1" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.15.0" version: "1.17.0"
path: path:
dependency: transitive dependency: transitive
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -132,18 +132,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.12.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.4"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -164,18 +164,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.10"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -185,5 +185,5 @@ packages:
source: hosted source: hosted
version: "14.2.5" version: "14.2.5"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"