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.
version:
revision: 20e59316b8b8474554b38493b8ca888794b0234a
channel: v1.7.8-hotfixes
revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa"
channel: "[user-branch]"
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 localPropertiesFile = rootProject.file('local.properties')
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')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
@@ -21,41 +21,30 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
compileSdkVersion 28
lintOptions {
disable 'InvalidPackage'
}
namespace "io.swipelab.ux_example"
compileSdk 35
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.swipelab.ux_example"
minSdkVersion 16
targetSdkVersion 28
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
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
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
flutter {
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"
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
android:name="io.flutter.app.FlutterApplication"
android:label="ux_example"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
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: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
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="true" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View File

@@ -1,13 +1,6 @@
package io.swipelab.ux_example;
import android.os.Bundle;
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;
import io.flutter.embedding.android.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"?>
<resources>
<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>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
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()
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins.each { name, path ->
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
include ":$name"
project(":$name").projectDir = pluginDirectory
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
}
include ":app"

View File

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

View File

@@ -1,5 +1,5 @@
# 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.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -10,63 +10,31 @@ project 'Runner', {
'Release' => :release,
}
def parse_KV_file(file, separator='=')
file_abs_path = File.expand_path(file)
if !File.exists? file_abs_path
return [];
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
pods_ary = []
skip_line_start_symbols = ["#", "/"]
File.foreach(file_abs_path) { |line|
next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ }
plugin = line.split(pattern=separator)
if plugin.length == 2
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
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
# Prepare symlinks folder. We use symlinks to avoid having Podfile.lock
# referring to absolute paths on developers' machines.
system('rm -rf .symlinks')
system('mkdir -p .symlinks/plugins')
use_frameworks!
# Flutter Pods
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')
}
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
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|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_BITCODE'] = 'NO'
end
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -3,16 +3,13 @@
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.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, ); }; };
792C661F9A831B1C3B10515D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 64E4EC7AC65146133031B4A4 /* Pods_Runner.framework */; };
9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; };
978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; };
97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; };
@@ -28,8 +25,6 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */,
9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@@ -37,16 +32,18 @@
/* End PBXCopyFilesBuildPhase 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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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; };
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>"; };
@@ -60,20 +57,35 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */,
3B80C3941E831B6300D905FE /* App.framework in Frameworks */,
792C661F9A831B1C3B10515D /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase 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 */ = {
isa = PBXGroup;
children = (
3B80C3931E831B6300D905FE /* App.framework */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEBA1CF902C7004384FC /* Flutter.framework */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
@@ -87,7 +99,8 @@
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
CF3B75C9A7D2FA2A4C99F110 /* Frameworks */,
3A74E1285E4DE31DB2DFB4A0 /* Pods */,
60EA09CFAEE451B185B2E653 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -130,12 +143,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
309E2F35ADAAFD20A04DD6E4 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
AE20327F75A2E81CD74E3319 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -152,7 +167,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1020;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "The Chromium Authors";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@@ -194,22 +209,47 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
309E2F35ADAAFD20A04DD6E4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
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";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
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 */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@@ -222,6 +262,24 @@
shellPath = /bin/sh;
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 */
/* Begin PBXSourcesBuildPhase section */
@@ -259,7 +317,6 @@
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
@@ -299,7 +356,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -313,14 +370,17 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = S8QB4VV633;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
@@ -333,7 +393,6 @@
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
@@ -379,7 +438,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -389,7 +448,6 @@
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
@@ -429,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -449,7 +507,10 @@
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
@@ -472,7 +533,10 @@
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,101 +1,156 @@
import 'dart:math' as math;
import 'package:flutter/material.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
State<KeyboardExample> createState() => _KeyboardExampleState();
State<ChatScreen> createState() => _ChatScreenState();
}
class _KeyboardExampleState extends State<KeyboardExample> {
class _ChatScreenState extends State<ChatScreen> {
final _keyboard = UxKeyboard.instance;
final _focusNode = FocusNode();
final _textController = TextEditingController();
final _messages = List.generate(30, (i) => 'Message ${i + 1}');
@override
void 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);
}
@override
void dispose() {
_keyboard.removeListener(_onKeyboard);
_keyboard.disableInteractiveDismiss();
_focusNode.dispose();
_textController.dispose();
super.dispose();
}
void _onKeyboard() => setState(() {});
void _send() {
final text = _textController.text.trim();
if (text.isEmpty) return;
setState(() => _messages.add(text));
_textController.clear();
}
@override
Widget build(BuildContext context) {
final bottomInset = _keyboard.height;
final safeArea = MediaQuery.paddingOf(context).bottom;
final bottom = bottomInset > 0 ? bottomInset : safeArea;
// Disable Flutter's built-in resize — we handle it ourselves.
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(title: Text('UxKeyboard')),
body: Column(
children: [
Expanded(
child: ListView.builder(
reverse: true,
padding: EdgeInsets.only(bottom: 60 + bottom, top: 16),
itemCount: 30,
itemBuilder: (context, i) => Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Align(
alignment: i % 3 == 0 ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: i % 3 == 0 ? Colors.blue[100] : Colors.grey[200],
borderRadius: BorderRadius.circular(16),
),
child: Text('Message ${30 - i}'),
appBar: AppBar(title: Text('UxKeyboard Chat')),
// ListenableBuilder rebuilds only when UxKeyboard notifies (height changes).
body: ListenableBuilder(
listenable: _keyboard,
builder: (context, _) {
final keyboardHeight = _keyboard.height;
final safeBottom = MediaQuery.viewPaddingOf(context).bottom;
final bottom = math.max(keyboardHeight, safeBottom);
return Column(
children: [
Expanded(
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ListView.builder(
// Freeze scrolling while the user is panning the keyboard down.
// This prevents the list from bouncing during interactive dismiss.
reverse: true,
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]),
),
),
);
},
),
),
),
),
),
Container(
padding: EdgeInsets.only(
left: 12,
right: 12,
top: 8,
bottom: 8 + bottom,
),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
children: [
Expanded(
child: TextField(
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
isDense: true,
// Input bar — sits directly above the keyboard.
Container(
padding: EdgeInsets.only(
left: 12,
right: 4,
top: 8,
bottom: 8 + bottom,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
SizedBox(width: 8),
IconButton(
icon: Icon(Icons.send, color: Colors.blue),
onPressed: () {},
child: Row(
children: [
Expanded(
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
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
@@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
@@ -71,63 +71,63 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.5"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.17.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
version: "0.0.0"
source_span:
dependency: transitive
description:
@@ -140,18 +140,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
string_scanner:
dependency: transitive
description:
@@ -172,25 +172,25 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "0.7.10"
ux:
dependency: "direct dev"
description:
path: ".."
relative: true
source: path
version: "0.1.1"
version: "0.2.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -200,5 +200,5 @@ packages:
source: hosted
version: "14.2.5"
sdks:
dart: ">=3.3.0 <4.0.0"
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -104,6 +104,7 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
// Pan gesture
private var keyboardOriginY: CGFloat = 0
fileprivate var snapBackAnimator: UIViewPropertyAnimator?
private var dismissAnimator: UIViewPropertyAnimator?
private var trackingInset: CGFloat = 0
// 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) {
guard !isTracking else { return }
guard let userInfo = notification.userInfo else { return }
let endFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero
let screenHeight = UIScreen.main.bounds.height
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
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
animTarget = height
@@ -161,6 +171,16 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
animGeneration &+= 1
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
} else {
keyboardFullHeight = 0
@@ -291,6 +311,8 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
case .changed:
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
guard keyboardFullHeight > 0, keyboardView != nil else { return }
@@ -352,6 +374,10 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
animDuration = 0.25
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
guard let kbView = self?.keyboardView else { return }
kbView.layer.bounds = CGRect(
@@ -360,9 +386,14 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
)
}
animator.addCompletion { [weak self] _ in
self?.dismissAnimator = nil
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)
}
dismissAnimator = animator
animator.startAnimation()
}

View File

@@ -1,5 +1,6 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/animation.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.
/// 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.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
@@ -71,22 +72,60 @@ const _kKeyboardSamples = <double>[
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 {
const _SampledCurve(this._samples);
final List<double> _samples;
/// Default head start — adaptive learning refines this from observations.
const _kDefaultHeadStart = 0.016;
@override
double transformInternal(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]);
}
/// EMA blending factor for adaptive updates.
const _kAdaptAlpha = 0.3;
/// Maximum error (in normalized progress) before the LUT is considered stale.
const _kAdaptThreshold = 0.02;
/// 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 {
UxKeyboard._() {
if (_lib == null) return;
@@ -110,46 +149,68 @@ class UxKeyboard with ChangeNotifier {
double _animStartTime = 0; // seconds, from frame timestamp
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) {
if (_uxKeyboardHeight == null) return;
final ts = timestamp.inMicroseconds / Duration.microsecondsPerSecond;
// On iOS, replay the animation in Dart with a head start (native only
// gives start/end via notification). On Android, WindowInsetsAnimation
// pushes per-frame values directly — no replay needed.
if (Platform.isIOS) {
final gen = _uxAnimGen?.call() ?? 0;
if (gen != _lastAnimGen) {
_lastAnimGen = gen;
final target = _uxAnimTarget?.call() ?? 0;
final duration = _uxAnimDuration?.call() ?? 0;
if (duration > 0) {
_animFrom = _height;
_animTo = target;
_animDuration = duration - 0.01; // finish 10ms ahead of native
_animStartTime = ts - 0.016; // compensate 2-frame pipeline delay
_isAnimating = true;
}
// Detect new animation generation (both platforms).
final gen = _uxAnimGen?.call() ?? 0;
if (gen != _lastAnimGen) {
_lastAnimGen = gen;
final target = _uxAnimTarget?.call() ?? 0;
final duration = _uxAnimDuration?.call() ?? 0;
if (duration > 0) {
_obs.clear(); // discard interrupted observations
_animFrom = _height;
_animTo = target;
_animDuration = duration;
_animStartTime = ts - _headStart;
_isAnimating = true;
} else {
// 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) {
_isAnimating = false;
_obs.clear();
}
double h;
if (_isAnimating) {
final elapsed = ts - _animStartTime;
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) {
_isAnimating = false;
h = _animTo;
if (!Platform.isIOS) _isAnimating = false;
_finishLearning();
}
} else {
// Fallback: read FFI directly (interactive dismiss, snap-back, etc.)
h = _uxKeyboardHeight!();
}
@@ -158,12 +219,95 @@ class UxKeyboard with ChangeNotifier {
notifyListeners();
}
// Keep scheduling frames while animating or keyboard is active
if (_isAnimating || (!Platform.isIOS && (h > 0 || _height > 0))) {
// Schedule frames while the curve is still running.
final curveActive = _isAnimating &&
(ts - _animStartTime) < _animDuration;
if (curveActive || (!Platform.isIOS && (h > 0 || _height > 0))) {
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 disableInteractiveDismiss() => _uxDisableInteractiveDismiss?.call();
}

View File

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