Skip to content

Commit 08a59c2

Browse files
authored
[iOS] Hide keyboard on hot restart (#167013)
This hides the keyboard and text input context menu if you hot restart your iOS app. Before | After -- | -- <video src="https://github.com/user-attachments/assets/7ca5dbfe-a809-478c-9b36-4c168527b176" /> | <video src="https://github.com/user-attachments/assets/d1a48c16-f171-4d22-baa4-5c40488d055b" /> Part of flutter/flutter#10713 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 46144a2 commit 08a59c2

14 files changed

Lines changed: 354 additions & 0 deletions

File tree

.ci.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5609,6 +5609,16 @@ targets:
56095609
["devicelab", "hostonly", "mac", "arm64"]
56105610
task_name: run_release_test_macos
56115611

5612+
- name: Mac_ios keyboard_hot_restart_ios
5613+
recipe: devicelab/devicelab_drone
5614+
bringup: true
5615+
presubmit: false
5616+
timeout: 60
5617+
properties:
5618+
tags: >
5619+
["devicelab", "ios", "mac"]
5620+
task_name: keyboard_hot_restart_ios
5621+
56125622
- name: Windows build_tests_1_9
56135623
recipe: flutter/flutter_drone
56145624
timeout: 60

TESTOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
/dev/devicelab/bin/tasks/ios_defines_test.dart @louisehsu @flutter/tool
205205
/dev/devicelab/bin/tasks/ios_picture_cache_complexity_scoring_perf__timeline_summary.dart @flar @flutter/engine
206206
/dev/devicelab/bin/tasks/ios_platform_view_tests.dart @stuartmorgan-g @flutter/plugin
207+
/dev/devicelab/bin/tasks/keyboard_hot_restart_ios.dart @loic-sharma @flutter/ios
207208
/dev/devicelab/bin/tasks/large_image_changer_perf_ios.dart @jtmcdole @flutter/engine
208209
/dev/devicelab/bin/tasks/microbenchmarks_ios.dart @louisehsu @flutter/engine
209210
/dev/devicelab/bin/tasks/native_assets_android.dart @dcharkes @flutter/android
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter_devicelab/framework/devices.dart';
6+
import 'package:flutter_devicelab/framework/framework.dart';
7+
import 'package:flutter_devicelab/tasks/keyboard_hot_restart_test.dart';
8+
9+
Future<void> main() async {
10+
await task(() async {
11+
deviceOperatingSystem = DeviceOperatingSystem.ios;
12+
return createKeyboardHotRestartTest()();
13+
});
14+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
import 'dart:io';
8+
9+
import 'package:path/path.dart' as path;
10+
11+
import '../framework/devices.dart';
12+
import '../framework/framework.dart';
13+
import '../framework/task_result.dart';
14+
import '../framework/utils.dart';
15+
16+
// This test verifies that hot restart hides the keyboard if it is visible.
17+
//
18+
// Steps:
19+
//
20+
// 1. Launch an app that focuses a text field at startup.
21+
// This makes the keyboard visible.
22+
// 2. Wait until the keyboard is visible.
23+
// 3. Update the app's source code to no longer focus a text field at startup.
24+
// 4. Hot restart the app
25+
// 5. Wait until the keyboard is no longer visible.
26+
//
27+
// App under test: //dev/integration_tests/keyboard_hot_restart/lib/main.dart
28+
//
29+
// Since this test must hot restart the app under test, this test cannot use
30+
// testing frameworks like XCUITest or Flutter's integration_test as they don't
31+
// support hot restart. Instead, this test uses the Flutter tool to run the app,
32+
// hot restart it, and verify its log output.
33+
TaskFunction createKeyboardHotRestartTest({
34+
String? deviceIdOverride,
35+
bool checkAppRunningOnLocalDevice = false,
36+
List<String>? additionalOptions,
37+
}) {
38+
final Directory appDir = dir(
39+
path.join(flutterDirectory.path, 'dev/integration_tests/keyboard_hot_restart'),
40+
);
41+
42+
// This file is modified during the test and needs to be restored at the end.
43+
final File mainFile = file(path.join(appDir.path, 'lib/main.dart'));
44+
final String oldContents = mainFile.readAsStringSync();
45+
46+
// When the test starts, the app forces the keyboard to be visible.
47+
// The test turns off this behavior by mutating the app's source code from
48+
// `forceKeyboardOn` to `forceKeyboardOff`.
49+
// See: //dev/integration_tests/keyboard_hot_restart/lib/main.dart
50+
const String forceKeyboardOn = 'const bool forceKeyboard = true;';
51+
const String forceKeyboardOff = 'const bool forceKeyboard = false;';
52+
53+
return () async {
54+
if (deviceIdOverride == null) {
55+
final Device device = await devices.workingDevice;
56+
await device.unlock();
57+
deviceIdOverride = device.deviceId;
58+
}
59+
60+
return inDirectory<TaskResult>(appDir, () async {
61+
try {
62+
section('Create app');
63+
await createAppProject();
64+
65+
// Ensure the app forces the keyboard to be visible.
66+
final String newContents = oldContents.replaceFirst(forceKeyboardOff, forceKeyboardOn);
67+
mainFile.writeAsStringSync(newContents);
68+
69+
section('Launch app and wait for keyboard to be visible');
70+
71+
TestState state = TestState.waitUntilKeyboardOpen;
72+
73+
final int exitCode = await runApp(
74+
options: <String>['-d', deviceIdOverride!],
75+
onLine: (String line, Process process) {
76+
if (state == TestState.waitUntilKeyboardOpen) {
77+
if (!line.contains('flutter: Keyboard is open')) {
78+
return;
79+
}
80+
81+
section('Update the app to no longer force the keyboard to be visible');
82+
final String newContents = oldContents.replaceFirst(
83+
forceKeyboardOn,
84+
forceKeyboardOff,
85+
);
86+
mainFile.writeAsStringSync(newContents);
87+
88+
section('Hot restart the app');
89+
process.stdin.writeln('R');
90+
91+
section('Wait until the keyboard is no longer visible');
92+
state = TestState.waitUntilKeyboardClosed;
93+
} else if (state == TestState.waitUntilKeyboardClosed) {
94+
if (!line.contains('flutter: Keyboard is closed')) {
95+
return;
96+
}
97+
98+
// Quit the app. This makes the 'flutter run' process exit.
99+
process.stdin.writeln('q');
100+
}
101+
},
102+
);
103+
104+
if (exitCode != 0) {
105+
return TaskResult.failure('flutter run exited with non-zero exit code: $exitCode');
106+
}
107+
} finally {
108+
mainFile.writeAsStringSync(oldContents);
109+
}
110+
111+
return TaskResult.success(null);
112+
});
113+
};
114+
}
115+
116+
enum TestState { waitUntilKeyboardOpen, waitUntilKeyboardClosed }
117+
118+
Future<void> createAppProject() async {
119+
await exec(path.join(flutterDirectory.path, 'bin', 'flutter'), <String>[
120+
'create',
121+
'--platforms=android,ios',
122+
'.',
123+
]);
124+
}
125+
126+
Future<int> runApp({
127+
required List<String> options,
128+
required void Function(String, Process) onLine,
129+
}) async {
130+
final Process process = await startFlutter('run', options: options);
131+
132+
final Completer<void> stdoutDone = Completer<void>();
133+
final Completer<void> stderrDone = Completer<void>();
134+
135+
void onStdout(String line) {
136+
onLine(line, process);
137+
print('stdout: $line');
138+
}
139+
140+
process.stdout
141+
.transform<String>(utf8.decoder)
142+
.transform<String>(const LineSplitter())
143+
.listen(onStdout, onDone: stdoutDone.complete);
144+
145+
process.stderr
146+
.transform<String>(utf8.decoder)
147+
.transform<String>(const LineSplitter())
148+
.listen((String line) => print('stderr: $line'), onDone: stderrDone.complete);
149+
150+
await Future.wait<void>(<Future<void>>[stdoutDone.future, stderrDone.future]);
151+
return process.exitCode;
152+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# keyboard_hot_restart
2+
3+
An app used to verify that the keyboard is not visible after a hot restart.
4+
5+
Test: `//dev/devicelab/lib/tasks/keyboard_hot_restart_test.dart`.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
// If true, the app autofocuses a text field, making the software keyboard visible.
8+
// The test changes this line while the app is running.
9+
// If you change this line, update the test as well.
10+
// See:
11+
// //dev/devicelab/lib/tasks/keyboard_hot_restart_test.dart
12+
const bool forceKeyboard = true;
13+
14+
void main() {
15+
runApp(const MyApp());
16+
}
17+
18+
class MyApp extends StatelessWidget {
19+
const MyApp({super.key});
20+
21+
@override
22+
Widget build(BuildContext context) {
23+
return const MaterialApp(home: MyHomePage());
24+
}
25+
}
26+
27+
class MyHomePage extends StatelessWidget {
28+
const MyHomePage({super.key});
29+
30+
@override
31+
Widget build(BuildContext context) {
32+
final EdgeInsets insets = MediaQuery.of(context).viewInsets;
33+
34+
// Print whether the keyboard is visible or not.
35+
// If you change this line, update the test as well.
36+
// See:
37+
// //dev/devicelab/lib/tasks/keyboard_hot_restart_test.dart
38+
// ignore: avoid_print
39+
print('Keyboard is ${insets.bottom > 0 ? 'open' : 'closed'}');
40+
41+
return const Scaffold(body: Center(child: TextField(autofocus: forceKeyboard)));
42+
}
43+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: keyboard_hot_restart
2+
description: A new Flutter project.
3+
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
4+
5+
version: 1.0.0+1
6+
7+
environment:
8+
sdk: ^3.7.0-0
9+
10+
dependencies:
11+
flutter:
12+
sdk: flutter
13+
14+
characters: 1.4.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
15+
collection: 1.19.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
16+
material_color_utilities: 0.11.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
17+
meta: 1.16.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
18+
vector_math: 2.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
19+
20+
flutter:
21+
uses-material-design: true
22+
23+
# PUBSPEC CHECKSUM: e4c7

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterRestorationPlugin.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717

1818
@property(nonatomic, copy) NSData* restorationData;
1919
- (void)markRestorationComplete;
20+
21+
/**
22+
* Reset the state restoration plugin to prepare for a hot restart.
23+
*
24+
* This clears the restoration data and drops any pending requests.
25+
*/
2026
- (void)reset;
2127
@end
2228
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERRESTORATIONPLUGIN_H_

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) {
4646

4747
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result;
4848

49+
/**
50+
* Reset the text input plugin to prepare for a hot restart.
51+
*
52+
* This hides the software keyboard and text editing context menu.
53+
*/
54+
- (void)reset;
55+
4956
/**
5057
* The `UITextInput` implementation used to control text entry.
5158
*

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2519,6 +2519,10 @@ - (void)removeEnableFlutterTextInputViewAccessibilityTimer {
25192519
return _activeView;
25202520
}
25212521

2522+
- (void)reset {
2523+
[self hideTextInput];
2524+
}
2525+
25222526
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
25232527
NSString* method = call.method;
25242528
id args = call.arguments;

0 commit comments

Comments
 (0)