Skip to content

Commit bef67bd

Browse files
committed
[green] keep iPad keyboard palette clear of editing controls
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent bd36731 commit bef67bd

4 files changed

Lines changed: 346 additions & 1 deletion

File tree

‎app/ios/Runner/AppDelegate.swift‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import UIKit
66

77
@main
88
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
9+
private var keyboardInsets: PybleKeyboardInsets?
10+
911
override func application(
1012
_ application: UIApplication,
1113
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -17,6 +19,7 @@ import UIKit
1719
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
1820
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PybleKeyboardDismissal")
1921
else { return }
22+
keyboardInsets = PybleKeyboardInsets(registrar: registrar)
2023
let keyboard = FlutterMethodChannel(
2124
name: "dev.pyble/keyboard", binaryMessenger: registrar.messenger())
2225
keyboard.setMethodCallHandler { [weak registrar] call, result in
@@ -31,3 +34,108 @@ import UIKit
3134
}
3235
}
3336
}
37+
38+
/// Measures the current Flutter view's public layout guide instead of keyboard
39+
/// notification frames, which can be offscreen after an iPad palette transition.
40+
private final class PybleKeyboardInsets {
41+
private weak var registrar: FlutterPluginRegistrar?
42+
private let channel: FlutterMethodChannel
43+
private var observer: PybleKeyboardGuideView?
44+
private var generation: Int?
45+
private var lastBottom: CGFloat?
46+
47+
init(registrar: FlutterPluginRegistrar) {
48+
self.registrar = registrar
49+
channel = FlutterMethodChannel(
50+
name: "dev.pyble/keyboard/insets", binaryMessenger: registrar.messenger())
51+
channel.setMethodCallHandler { [weak self] call, result in
52+
guard let self = self, let epoch = call.arguments as? Int else {
53+
result(FlutterMethodNotImplemented)
54+
return
55+
}
56+
switch call.method {
57+
case "observe":
58+
self.observe(generation: epoch)
59+
result(nil)
60+
case "stop":
61+
if self.generation == epoch { self.stop() }
62+
result(nil)
63+
default:
64+
result(FlutterMethodNotImplemented)
65+
}
66+
}
67+
}
68+
69+
private func observe(generation: Int) {
70+
if self.generation != generation {
71+
stop()
72+
self.generation = generation
73+
}
74+
guard let view = registrar?.viewController?.viewIfLoaded else {
75+
observer?.removeFromSuperview()
76+
observer = nil
77+
publish(0)
78+
return
79+
}
80+
if observer?.superview !== view {
81+
observer?.removeFromSuperview()
82+
let guideView = PybleKeyboardGuideView()
83+
guideView.translatesAutoresizingMaskIntoConstraints = false
84+
guideView.isUserInteractionEnabled = false
85+
guideView.accessibilityElementsHidden = true
86+
guideView.backgroundColor = .clear
87+
guideView.changed = { [weak self] bottom in self?.publish(bottom) }
88+
observer = guideView
89+
view.addSubview(guideView)
90+
NSLayoutConstraint.activate([
91+
guideView.topAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
92+
guideView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
93+
guideView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
94+
guideView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
95+
])
96+
view.layoutIfNeeded()
97+
}
98+
// An explicit refresh must return a snapshot even if UIKit has not moved:
99+
// Dart may have rejected the previous value while its window size lagged.
100+
lastBottom = nil
101+
observer?.reportOverlap()
102+
}
103+
104+
private func publish(_ bottom: CGFloat) {
105+
guard let generation = generation, bottom != lastBottom else { return }
106+
lastBottom = bottom
107+
channel.invokeMethod("inset", arguments: ["generation": generation, "bottom": bottom])
108+
}
109+
110+
private func stop() {
111+
generation = nil
112+
observer?.removeFromSuperview()
113+
observer = nil
114+
lastBottom = nil
115+
}
116+
}
117+
118+
private final class PybleKeyboardGuideView: UIView {
119+
var changed: ((CGFloat) -> Void)?
120+
121+
override func layoutSubviews() {
122+
super.layoutSubviews()
123+
reportOverlap()
124+
}
125+
126+
override func didMoveToWindow() {
127+
super.didMoveToWindow()
128+
reportOverlap()
129+
}
130+
131+
func reportOverlap() {
132+
guard let owner = superview, window != nil else {
133+
changed?(0)
134+
return
135+
}
136+
// On iOS 15/16 a hidden/undocked guide ends at the safe-area bottom.
137+
// A real docked keyboard or input palette reports its full overlap.
138+
let overlap = min(owner.bounds.height, max(0, bounds.height))
139+
changed?(overlap <= owner.safeAreaInsets.bottom + 0.5 ? 0 : overlap)
140+
}
141+
}

‎app/lib/app/app.dart‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import 'package:pyble/editor/recovery_gate.dart';
1919
import 'package:pyble/theme/theme.dart';
2020

2121
import 'shell.dart';
22+
import 'keyboard_insets.dart';
2223

2324
/// Navigator used by explicit user confirmations, never by local recovery itself.
2425
final GlobalKey<NavigatorState> appNavigatorKey = GlobalKey<NavigatorState>();
@@ -45,6 +46,8 @@ class PybleApp extends StatelessWidget {
4546
highContrastDarkTheme: PybleTheme.highContrastDark,
4647
themeMode: ThemeMode.system,
4748
debugShowCheckedModeBanner: false,
49+
builder: (BuildContext context, Widget? child) =>
50+
KeyboardInsets(child: child ?? const SizedBox.shrink()),
4851
home: const RecoveryGate(child: PybleShell()),
4952
);
5053
}

‎app/lib/app/keyboard_insets.dart‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import 'dart:async';
5+
import 'dart:math' as math;
6+
7+
import 'package:flutter/foundation.dart';
8+
import 'package:flutter/services.dart';
9+
import 'package:flutter/widgets.dart';
10+
11+
const MethodChannel _channel = MethodChannel('dev.pyble/keyboard/insets');
12+
13+
/// Supplements notification-derived iOS metrics with the current window's
14+
/// keyboard layout guide. UIKit points and Flutter logical pixels coincide.
15+
class KeyboardInsets extends StatefulWidget {
16+
const KeyboardInsets({required this.child, super.key});
17+
18+
final Widget child;
19+
20+
@override
21+
State<KeyboardInsets> createState() => _KeyboardInsetsState();
22+
}
23+
24+
class _KeyboardInsetsState extends State<KeyboardInsets>
25+
with WidgetsBindingObserver {
26+
static int _nextGeneration = 0;
27+
late final bool _enabled =
28+
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
29+
int? _generation;
30+
double _nativeBottom = 0;
31+
double _viewHeight = 0;
32+
bool _refreshScheduled = false;
33+
34+
@override
35+
void initState() {
36+
super.initState();
37+
if (!_enabled) return;
38+
_channel.setMethodCallHandler(_handleInset);
39+
WidgetsBinding.instance.addObserver(this);
40+
_generation = ++_nextGeneration;
41+
_refreshAfterFrame();
42+
}
43+
44+
Future<void> _handleInset(MethodCall call) async {
45+
final Object? arguments = call.arguments;
46+
if (!mounted ||
47+
_generation == null ||
48+
call.method != 'inset' ||
49+
arguments is! Map ||
50+
arguments['generation'] != _generation) {
51+
return;
52+
}
53+
final Object? raw = arguments['bottom'];
54+
if (raw is! num) return;
55+
final double bottom = raw.toDouble();
56+
if (!bottom.isFinite || bottom < 0 || bottom > _viewHeight) return;
57+
if (_nativeBottom != bottom) setState(() => _nativeBottom = bottom);
58+
}
59+
60+
Future<void> _request(String method, int generation) async {
61+
try {
62+
await _channel.invokeMethod<void>(method, generation);
63+
} on MissingPluginException {
64+
// Host-only widget tests have no UIKit registrar. Flutter's ordinary
65+
// metrics remain usable; the shipping iOS runner registers this channel.
66+
}
67+
}
68+
69+
void _refreshAfterFrame() {
70+
if (_refreshScheduled || _generation == null) return;
71+
_refreshScheduled = true;
72+
WidgetsBinding.instance.addPostFrameCallback((_) {
73+
_refreshScheduled = false;
74+
final int? generation = _generation;
75+
if (mounted && generation != null) {
76+
unawaited(_request('observe', generation));
77+
}
78+
});
79+
}
80+
81+
@override
82+
void didChangeMetrics() => _refreshAfterFrame();
83+
84+
@override
85+
void didChangeAppLifecycleState(AppLifecycleState state) {
86+
if (state == AppLifecycleState.paused ||
87+
state == AppLifecycleState.detached) {
88+
_stop();
89+
if (_nativeBottom != 0) setState(() => _nativeBottom = 0);
90+
} else if (state == AppLifecycleState.resumed) {
91+
_generation ??= ++_nextGeneration;
92+
_refreshAfterFrame();
93+
}
94+
}
95+
96+
void _stop() {
97+
final int? generation = _generation;
98+
_generation = null;
99+
if (generation != null) unawaited(_request('stop', generation));
100+
}
101+
102+
@override
103+
void dispose() {
104+
if (_enabled) {
105+
_stop();
106+
WidgetsBinding.instance.removeObserver(this);
107+
_channel.setMethodCallHandler(null);
108+
}
109+
super.dispose();
110+
}
111+
112+
@override
113+
Widget build(BuildContext context) {
114+
if (!_enabled) return widget.child;
115+
final MediaQueryData original = MediaQuery.of(context);
116+
_viewHeight = original.size.height;
117+
final double bottom = math.max(
118+
original.viewInsets.bottom,
119+
_nativeBottom.clamp(0, _viewHeight),
120+
);
121+
return MediaQuery(
122+
data: original.copyWith(
123+
viewInsets: original.viewInsets.copyWith(bottom: bottom),
124+
padding: original.padding.copyWith(
125+
bottom: math.max(0, original.viewPadding.bottom - bottom),
126+
),
127+
),
128+
child: widget.child,
129+
);
130+
}
131+
}

0 commit comments

Comments
 (0)