Flutter 中自定义键盘
相关API解读
- 背景
- 1.基础 API
- 2.Window
- 3.BindingBase
- 4.ServicesBinding
- 5.Flutter 中如何弹出系统键盘?
- 6.InsightBank 如何弹出自定义键盘
- 7.如何解决自定义键盘与原生键盘互相切换问题?
背景
这是关于 bug 的故事。
为了解决自定义键盘与原生键盘互相切换bottom计算不准确的问题,研究了系统键盘与自定义键盘是如何工作的。
- didChangeMetrics() 如何计算键盘的高度
- debug 模式切换键盘没有问题,到 relase 版本下却不行了?
一、基础 API
-
TextInputType:
定义各种键盘的类型,与 Native中接口相同(number、phone、email...) -
CustomTextInputType:
目前只有Android平台使用。继承 TextInputType 自定义键盘类型。 -
FocusNode
: StatefullWidget 可以使用一个对象来获取键盘焦点和处理键盘事件。 -
TextEditingController:
增删改查。输入的信息(需要在 dispos 中回收资源)。 -
InputController:
validate用于检测文本信息
二、 window
它公开了显示的大小,核心调度程序API,输入事件回调(语言改变、 textScaleFactor 改变回调 ),图形绘制API以及其他此类核心服务。
MediaQuery 的 MediaQueryData 来源于 WidgetsBinding.instance.window
https://flutter.github.io/assets-for-api-docs/assets/widgets/window_padding.mp4
-
Window.viewInsets:
系统为系统UI(例如键盘)保留的物理像素,它将完全遮盖该区域中绘制的所有内容。onMetricsChanged 可以监听到发生改变 -
Window.viewPadding:
是显示器每一侧的物理像素,可能会由于系统UI或显示器的物理侵入而被部分遮挡 -
Window.padding:
它将允许viewInsets插图消耗视图填充
Window.viewInsets 更新流程
-
Engine
通过_updateWindowMetrics()
更新window
-
window
通过handleMetricsChanged()
调用RendererBingding
和widgetsBinding
去更新UI,回调didChangeMetrics()
三、BindingBase
- 各种Binding服务 minxin 的基类
- 提供window
- 提供锁事件处理,用于热加载。热加载时会把所有输入事件锁起来,等热加载完成再执行。
/// 提供单例服务的mixin们的基类
///
/// 使用on继承此类并实现initInstances方法 这个mixin在app的生命周期内只能被构建一次,在checked
/// 模式下会对此断言
///
/// 用于编写应用程序的最顶层将具有一个继承自[BindingBase]并使用所有各种[BindingBase]
/// mixins(例如[ServicesBinding])的具体类。
/// 比如Flutter中的Widgets库引入了一个名为[WidgetsFlutterBinding]的binding,定义了如何绑定
/// 可以隐性(例如,[WidgetsFlutterBinding]从[runApp]启动),或者需要应用程序显式调用构造函数
abstract class BindingBase {
BindingBase() {
initInstances();//在构造函数里进行初始化
initServiceExtensions();//初始化扩展服务
}
ui.Window get window => ui.window;//提供window
@protected
@mustCallSuper
void initInstances() {//初始化,其他binding mixin可以重写此类
}
@protected
@mustCallSuper
void initServiceExtensions() {}//用于子类重写该方法,用于注册一些扩展服务。
@protected
bool get locked => _lockCount > 0;
int _lockCount = 0;
/// /锁定异步事件和回调的分派,直到回调的未来完成为止。
/// 这会导致输入滞后,因此应尽可能避免。 它主要用于非用户交互时间
@protected
Future<void> lockEvents(Future<void> callback()) {
developer.Timeline.startSync('Lock events');
_lockCount += 1;
final Future<void> future = callback();
future.whenComplete(() {
_lockCount -= 1;
if (!locked) {
unlocked();
}
});
return future;
}
@protected
@mustCallSuper
void unlocked() {//解锁
assert(!locked);
}
/// 开发过程中使用
///
/// 使整个应用程序重新绘制,例如热装后。
///通过发送`ext.flutter.reassemble`服务扩展信号来手动进行
/// 在此方法运行时,事件被锁定(例如,指针事件未锁定)
Future<void> reassembleApplication() {
return lockEvents(performReassemble);
}
/// 不会继续指向旧代码,并刷新以前计算的值的所有缓存,以防新代码对它们进行不同的计算。
/// 例如,渲染层在调用时触发整个应用程序重新绘制。
@mustCallSuper
@protected
Future<void> performReassemble() {//重绘方法,需要重写
return Future<void>.value();
}
···省略一些服务扩展方法
}
其他 Bingding
-
GestureBinding:
提供了window.onPointerDataPacket 回调,绑定Framework手势子系统,是Framework事件模型与底层事件的绑定入口。 -
ServicesBinding:
提供了window.onPlatformMessage 回调, 用于绑定平台消息通道(message channel),主要处理原生和Flutter通信。 -
SchedulerBinding:
提供了window.onBeginFrame和window.onDrawFrame回调,监听刷新事件,绑定Framework绘制调度子系统。 -
PaintingBinding:
绑定绘制库,主要用于处理图片缓存。 -
SemanticsBinding:
语义化层与Flutter engine的桥梁,主要是辅助功能的底层支持。 -
RendererBinding:
提供了window.onMetricsChanged 、window.onTextScaleFactorChanged 等回调。它是渲染树与Flutter engine的桥梁。 -
WidgetsBinding:
提供了window.onLocaleChanged、onBuildScheduled 等回调。它是Flutter widget层与engine的桥梁。
四、ServicesBinding mixin
侦听平台消息,并将其定向到defaultBinaryMessenger
。另注册了 LicenseEntryCollector
做证书相关处理。
DefaultBinaryMessenger 继承自 BinaryMessenger,BinaryMessenger 是一个信使,它通过Flutter平台发送二进制数据。
DefaultBinaryMessenger 继承自 BinaryMessenger
/// 一个信使,它通过Flutter平台发送二进制数据。
abstract class BinaryMessenger {
/// A const constructor to allow subclasses to be const.
const BinaryMessenger();
/// 设置一个回调,以在给定通道上接收来自平台插件的消息,而无需对其进行解码。
/// 给定的回调将替换当前注册的回调频道(如果有)。 要删除处理程序,请将null作为[handler]参数传递。
void setMessageHandler(String channel, Future<ByteData> handler(ByteData message));
/// 与上述类似,但不会回调native端
void setMockMessageHandler(String channel, Future<ByteData> handler(ByteData message));
/// 将二进制消息发送到给定通道上的平台插件。返回一个二进制的Future
Future<ByteData> send(String channel, ByteData message);
/// 处理 native 端发来的消息
Future<void> handlePlatformMessage(String channel, ByteData data, ui.PlatformMessageResponseCallback callback);
}
DefaultBinaryMessenger
image.png另外
PlatformBinaryMessenger
与 DefaultBinaryMessenger 实现类似,不同点在于 PlatformBinaryMessenger 不支持 setMockMessageHandler() 。
-
setMessageHandler
:以 channel 为 key,MessageHandler 为 value ,放入 ChannelBuffer 中。 -
setMockMessageHandler:
以 channel 为 key,MessageHandler 为 value 。实现自定义 channel 方法。 -
ChannelBuffer
: 允许在 Engine 和 Framework 之间存储消息。在 Framework 处理之前,消息会一直存储。
/// The default implementation of [BinaryMessenger].
/// 发送消息到 native,并处理 native 返回的消息
class _DefaultBinaryMessenger extends BinaryMessenger {
const _DefaultBinaryMessenger._();
static final Map<String, MessageHandler> _handlers =
<String, MessageHandler>{};
static final Map<String, MessageHandler> _mockHandlers =
<String, MessageHandler>{};
Future<ByteData> _sendPlatformMessage(String channel, ByteData message) {
final Completer<ByteData> completer = Completer<ByteData>();
/// ui.window.sendPlatformMessage 调用 C++ 的方法。
ui.window.sendPlatformMessage(channel, message, (ByteData reply) {
try {
completer.complete(reply);
} catch (exception, stack) {
...
}
});
return completer.future;
}
@override
Future<void> handlePlatformMessage(
String channel,
ByteData data,
ui.PlatformMessageResponseCallback callback,
) async {
ByteData response;
try {
final MessageHandler handler = _handlers[channel];
if (handler != null) {
response = await handler(data);
} else {
/// 存储通道消息,直到通道被完全路由为止,即,当消息处理程序附加到框架侧的通道时。
ui.channelBuffers.push(channel, data, callback);
callback = null;
}
} catch (exception, stack) {
...
} finally {
if (callback != null) {
callback(response);
}
}
}
@override
Future<ByteData> send(String channel, ByteData message) {
final MessageHandler handler = _mockHandlers[channel];
if (handler != null)
return handler(message);
return _sendPlatformMessage(channel, message);
}
@override
void setMessageHandler(String channel, MessageHandler handler) {
if (handler == null)
_handlers.remove(channel);
else
_handlers[channel] = handler;
/// 允许在引擎和框架之间存储消息。
ui.channelBuffers.drain(channel, (ByteData data, ui.PlatformMessageResponseCallback callback) async {
await handlePlatformMessage(channel, data, callback);
});
}
@override
void setMockMessageHandler(String channel, MessageHandler handler) {
if (handler == null)
_mockHandlers.remove(channel);
else
_mockHandlers[channel] = handler;
}
}
五、Flutter 中如何弹出系统键盘?
image.png- 1.从
FocusManager
开始,requestFocus() 获得 inputWidget 输入的焦点
FocusScope.of(context).requestFocus(focusNode)
-
FocusManager
调用内部的_listeners
列表, 执行EditableText._handleFocusChanged()
,EditableText 会在 initState() 和 didUpdateWidget() 中去注册监听。
-
/// 解决自定义键盘与原生键盘提供方案。
@override
void didChangeMetrics() {
// window.viewInsets.bottom 用来获得键盘的高度
if (_lastBottomViewInset < WidgetsBinding.instance.window.viewInsets.bottom) {
_showCaretOnScreen();// 计算屏幕中位置
}
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
}
/// EditableText 1706
void _handleFocusChanged() {
/// 打开和关闭输入连接
_openOrCloseInputConnectionIfNeeded();
// 播放 Cursor 动画
_startOrStopCursorTimerIfNeeded();
// SelectionOverlay 复制粘贴相关
_updateOrDisposeSelectionOverlayIfNeeded();
if (_hasFocus) {
// Listen for changing viewInsets, which indicates keyboard showing up.
// 监听viewInsets的改变,当键盘出现的时候
WidgetsBinding.instance.addObserver(this);
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
_showCaretOnScreen();
if (!_value.selection.isValid) {
// 如果我们收到焦点时选择无效,请将光标放在末尾。
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null);
}
} else {
WidgetsBinding.instance.removeObserver(this);
// 如果失去焦点,则清除选择和合成状态。
_value = TextEditingValue(text: _value.text);
}
}
-
_openOrCloseInputConnectionIfNeeded()
中会建立与 Native 键盘的连接。同时通过TextInput.attach
关联键盘事件。
-
/// EditableText 1391的构造方法
void _openInputConnection() {
if (!_hasInputConnection) { // 注册与原生的通信
final TextEditingValue localValue = _value;
_lastKnownRemoteTextEditingValue = localValue;
_textInputConnection = TextInput.attach(//
this,
TextInputConfiguration(
inputType: widget.keyboardType,
...
),
);
_textInputConnection.show(); // 展示原生键盘
_updateSizeAndTransform();
final TextStyle style = widget.style;
_textInputConnection
..setStyle(
fontFamily: style.fontFamily,
...
)
..setEditingState(localValue);
} else {
_textInputConnection.show();
}
}
(1)_hasInputConnection
方法会初始化 TextInput()
对象,调用 binaryMessenger.setMethodCallHandler(),创建与原生的通信。_handleTextInputInvocation() 注册了交互的事件,比如:更新光标的位置、给默认值、关闭连接.
/// TextInput 828 的构造方法
TextInput._() {
_channel = OptionalMethodChannel(
'flutter/textinput',
JSONMethodCodec(),
);
_channel.setMethodCallHandler(_handleTextInputInvocation);
}
/// 监听键盘输入中事件
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
// The incoming message was for a different client.
if (client != _currentConnection._id)
return;
switch (method) {
case 'TextInputClient.updateEditingState':
// 输入事件,更新输入焦点、暂停,开始光标动画
_currentConnection._client.updateEditingValue(TextEditingValue.fromJSON(args[1]));
break;
case 'TextInputClient.performAction':
// TextInputAction.send,go 事件等。通过 onSubmitted() 回调
_currentConnection._client.performAction(_toTextInputAction(args[1]));
break;
case 'TextInputClient.updateFloatingCursor':
// 更新浮动光标的位置和状态。
_currentConnection._client.updateFloatingCursor(_toTextPoint(_toTextCursorAction(args[1]), args[2]));
break;
case 'TextInputClient.onConnectionClosed':
_currentConnection._client.connectionClosed();
break;
default:
throw MissingPluginException();
}
}
(2) TextInput.attach()
调用'TextInput.setClient'
方法与 native 建立连接,configuration
信息传入传给native,例如键盘的类型。
void _attach(TextInputConnection connection, TextInputConfiguration configuration) {
final ByteData result = await binaryMessenger.send(
'TextInput.setClient',
codec.encodeMethodCall(MethodCall(method, arguments)),
);
_currentConnection = connection;
_currentConfiguration = configuration;
}
(3) _textInputConnection.show()
调用 'TextInput.show
方法显示键盘。
(4) _updateSizeAndTransform()
方法,调用 TextInput.setEditableSizeAndTransform
将文本大小参数传递过去,设置输入文字的大小
-
_startOrStopCursorTimerIfNeeded()
使用 Timer 控制光标。
-
- 5
_updateOrDisposeSelectionOverlayIfNeeded()
选中的文本 '复制/粘贴' 相关配置。
void _handleFocusChanged() {
_openOrCloseInputConnectionIfNeeded(); /// 建立关闭连接
_startOrStopCursorTimerIfNeeded(); /// 光标
_updateOrDisposeSelectionOverlayIfNeeded(); /// 选择的文本
if (_hasFocus) {
// Listen for changing viewInsets, which indicates keyboard showing up.
WidgetsBinding.instance.addObserver(this);
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom; /// 获取系统键盘的高度更新视图
_showCaretOnScreen();
if (!_value.selection.isValid) { /// TextSelectionOverlay 复制/粘贴 布局
// Place cursor at the end if the selection is invalid when we receive focus.
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null);
}
} else {
WidgetsBinding.instance.removeObserver(this);
// Clear the selection and composition state if this widget lost focus.
_value = TextEditingValue(text: _value.text);
}
updateKeepAlive();
}
六、InsightBank 中如何调用自定义键盘
键盘相关的事件
-
TextInput.show:
展示自定义键盘 -
TextInput.hide:
隐藏自定义键盘 -
TextInput.setClient
: 初始化键盘,设置键盘类型等,并监听输入事件 -
TextInput.clearClient
:关闭键盘,内存回收 -
TextInput.setEditingState
:设置编辑状态。光标的位置,当前文本 -
TextInput.setEditableSizeAndTransform
: 设置宽高相关信息 -
TextInput.setStyle
: 设置文本字体
static void _interceptInput() {
// custom keyboard will cover the driver's MockMessageHandler
// so we disable custom keyboard when run driver test
if (_isIntercepted || moduleConfig.isDriverTestMode) return;
_isIntercepted = true;
ServicesBinding.instance.defaultBinaryMessenger.setMockMessageHandler(
_channelTextInput,
(ByteData data) async {
final methodCall = _codec.decodeMethodCall(data);
switch (methodCall.method) {
case _methodShow:
return _handleShow(methodCall, data);
case _methodHide:
return _handleHide(methodCall, data);
case _methodSetClient:
return _handleSetClient(methodCall, data);
case _methodClearClient:
return _handleClearClient(methodCall, data);
case _methodSetEditingState:
return _handleSetEditingState(methodCall, data);
default:
return _dispatchOriginResponse(data);
}
},
);
}
七、如何解决自定义键盘与原生键盘互相切换问题
(1)之前的解决方案
final insets = window.viewInsets.bottom / window.devicePixelRatio;
就是键盘的高度
(2)_showCaretOnScreen()
_showCaretOnScreen() 起到的作用如下
- 计算文字,横向滚动
- 计算 bottomSpacing ,将输入框显示在屏幕上合适的位置。
问题:
-
为什么使用的是:
addPostFrameCallback
是否可以换成setState()
。
换成 setState 后输入文字出现闪烁的情况 -
debug
模式下,键盘互相切换没有问题,release
版本却不行(偶现问题)。
我猜想,一下前提条件:
- _showCaretOnScreenScheduled 控制 _showCaretOnScree() 调用
- setState() 不会调用 _showCaretOnScreen(),意味着收起键盘不会调用
- window 更新 vidwinsets 是异步的。
由于 release 版本更快,window 更新时,_showCaretOnScreenScheduled 控制着 _showCaretOnScree() 无法更新,所以出现了输入框被遮挡的情况。
同理 debug 版本相对慢,意味着 _showCaretOnScree() 执行完成后,因为 window 更新又执行了一遍
@override
void didChangeMetrics() {
// 展示键盘的事件
if (_lastBottomViewInset < WidgetsBinding.instance.window.viewInsets.bottom) {
_showCaretOnScreen();
}
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
}
// 调用时机,didChangeMetrics(), initState(), 输入字符时候。
void _showCaretOnScreen() {
if (_showCaretOnScreenScheduled) {
return;
}
_showCaretOnScreenScheduled = true;
// 这里没有使用 setState 而是使用了 addPostFrameCallback
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_showCaretOnScreenScheduled = false;
if (_currentCaretRect == null || !_scrollController.hasClients) {
return;
}
final double scrollOffsetForCaret = _getScrollOffsetForCaret(_currentCaretRect);
/// 1. 文字横向滚动,如果输入文字超过一屏幕
_scrollController.animateTo(
scrollOffsetForCaret,
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
final Rect newCaretRect = _getCaretRectAtScrollOffset(_currentCaretRect, scrollOffsetForCaret);
// Enlarge newCaretRect by scrollPadding to ensure that caret is not
// positioned directly at the edge after scrolling.
double bottomSpacing = widget.scrollPadding.bottom;
if (_selectionOverlay?.selectionControls != null) {
final double handleHeight = _selectionOverlay.selectionControls
.getHandleSize(renderEditable.preferredLineHeight).height;
final double interactiveHandleHeight = math.max(
handleHeight,
kMinInteractiveDimension,
);
final Offset anchor = _selectionOverlay.selectionControls
.getHandleAnchor(
TextSelectionHandleType.collapsed,
renderEditable.preferredLineHeight,
);
final double handleCenter = handleHeight / 2 - anchor.dy;
bottomSpacing = math.max(
handleCenter + interactiveHandleHeight / 2,
bottomSpacing,
);
}
final Rect inflatedRect = Rect.fromLTRB(
newCaretRect.left - widget.scrollPadding.left,
newCaretRect.top - widget.scrollPadding.top,
newCaretRect.right + widget.scrollPadding.right,
newCaretRect.bottom + bottomSpacing,
);
/// 2. 找到输入框光标的位置,显示在屏幕上
_editableKey.currentContext.findRenderObject().showOnScreen(
rect: inflatedRect,
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
});
}
总结:
- nativeBottomInsets 可以通过 window 直接获取。
- addPostFrameCallback() 具有延迟计算的作用,第一帧使用老的 insets,这样焦点还在原来的位置不变。
即使,出现了以上情况,
https://phabricator.d.xiaomi.net/D224058
static double get nativeBottomInsets => window.viewInsets.bottom / window.devicePixelRatio;
/// 在 ScrollView 中,原生键盘切换自定义键盘,会发生 ScrollView 滚动位置不准确问题。
///
/// 原因:ScrollView 的计算依赖与第一帧 viewInsets 去计算。
/// updateBottomInsets() 会在 [window] 更新 viewInsets 之前触发。所以使用 [onPostFrame] 延迟处理。
/// 另外,setState() 可以更新 viewInsets 重绘,但在 release 版本下会变得不可控。
void updateBottomInsets(double insets) {
// 收起自定义键盘时,不能立即更新 insets ,会造成 ScrollView 滚动位置不准确。
if (insets < customBottomInsets) {
return UiUtils.onPostFrame(() => setState(() => customBottomInsets = insets));
}
// 展示自定义键盘需要立刻更新 insets, 会造成 ScrollView 滚动位置不准确
setState(() => customBottomInsets = insets);
}`