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:
@@ -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}}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
|
||||
android.enableR8=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>8.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -41,5 +41,9 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 0–50ms
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
64
pubspec.lock
64
pubspec.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user