Flutter web 集成 iframe 后输入框聚焦弹出键盘的界面滚动问题处理

604 阅读8分钟

最近用 Flutter 重构我的笔记本项目 Paper,其中需要在 Flutter 页面中嵌入 iframe 来显示富文本编辑器,在集成的过程中碰到了一些问题。

问题 1 - iframe 获得焦点弹出键盘后造成 Flutter 页面整体上移

我们的 Flutter 页面通常使用 Scaffold 组件包裹(我这里用的是 iOS 风格的 CupertinoPageScaffold),其中一个参数 resizeToAvoidBottomInset 的作用是告诉组件是否自动 resize 页面来避免底部界面被键盘遮挡,默认为 true。在 Flutter 自带的输入控件(TextField 或 CupertinoTextField)获得焦点弹出键盘后会自动滚动进入视野,并且页面会自动适应弹出键盘后的屏幕尺寸。但是在使用 HtmlElementView 组件集成的原生 html 控件获得焦点弹出键盘后,Flutter 页面没有自动适应大小。如果原生 html 输入控件的位置在键盘区域的话还会造成整体页面向上滚动,这是浏览器为了让输入控件在获得焦点时自动进入视野的操作造成的。

在原生 html 控件获得焦点弹出键盘后使 Flutter 页面自适应大小

上面我们说过 Flutter 页面适应弹出键盘后的页面是 Scaffold 控件的 resizeToAvoidBottomInset 参数控制的。我们先来看一下源码(这里我用 CupertinoPageScaffold 的源码举例)

final double bottomPadding = widget.resizeToAvoidBottomInset
    ? existingMediaQuery.viewInsets.bottom
    : 0.0;
paddedContent = Padding(
  padding: EdgeInsets.only(bottom: bottomPadding),
  child: paddedContent,
);

其中的的关键代码在 build 方法中,可以看到如果 resizeToAvoidBottomInsettrue 的话会自动在页面下方加入一个 Padding ,且其 bottom 值为 mediaQuery.viewInsets.bottom。要是我没猜错的话在 html 原生控件获得焦点弹出键盘后该值没有变化导致页面没有适应大小,调试一看果然不出所料。下面要解决这个问题有两个选择,要么找到值不变化的原因并修复,要么自己计算出该值写进去。正常情况下一般倾向于前者,因为这是解决根本问题的方式,而后者属于逃避问题的做法。为什么我会列出后者呢?下面细说。

要找到 mediaQuery.viewInsets.bottom 值不正确的问题所在,我们要先找到这个值是在哪里产生的。我们知道 MediaQuery 是用来在共享 MediaQueryData 数据给下级组件的,且我们的 App 是包裹在 MediaQuery.fromWindow 中的,

return MediaQuery.fromWindow(
  child: CupertinoApp.router(
    routerDelegate: MyRouterDelegate(),
    routeInformationParser: MyRouteParser(),
  ),
);

我们再看 MediaQuery.fromWindow 的源码,其中只简单返回了 _MediaQueryFromWindow 组件,我们继续追溯它,

class _MediaQueryFromWindowState extends State<_MediaQueryFromWindow> with WidgetsBindingObserver {
  @override
  void didChangeMetrics() {
    setState(() {
      // The properties of window have changed. We use them in our build
      // function, so we need setState(), but we don't cache anything locally.
    });
  }

  @override    
  Widget build(BuildContext context) {
    MediaQueryData data = MediaQueryData.fromWindow(WidgetsBinding.instance.window);
    if (!kReleaseMode) {
      data = data.copyWith(platformBrightness: debugBrightnessOverride);
    }
    return MediaQuery(
      data: data,
      child: widget.child,
    );
  }
}

可以看到该组件是一个有状态组件,且其继承了 WidgetsBindingObserver,其中重写了 didChangeMetrics 方法,根据注释得知该方法会在屏幕大小变化后被调用,这里单纯调用了 setState 方法使组件重新 build,在 build 方法中我们可以看到该组件只是在子组件外包了一层 MediaQuery 来设置初始值,且初始值为 WidgetsBinding.instance.window,继续追溯。

通过一番源码追溯,最终在 Flutter engine 的源码bin/cache/flutter_web_sdk/lib/ui/src/engine/window.dart 中找到了它,

class EngineFlutterWindow extends ui.SingletonFlutterWindow {
  void computeOnScreenKeyboardInsets(bool isEditingOnMobile) {
    double windowInnerHeight;
    final DomVisualViewport? viewport = domWindow.visualViewport;
    if (viewport != null) {
      if (operatingSystem == OperatingSystem.iOs && !isEditingOnMobile) {
        windowInnerHeight =
            domDocument.documentElement!.clientHeight * devicePixelRatio;
      } else {
        windowInnerHeight = viewport.height!.toDouble() * devicePixelRatio;
      }
    } else {
      windowInnerHeight = domWindow.innerHeight! * devicePixelRatio;
    }
    final double bottomPadding = _physicalSize!.height - windowInnerHeight;
    _viewInsets =
        WindowPadding(bottom: bottomPadding, left: 0, right: 0, top: 0);
  }

  @override
  WindowPadding get viewInsets => _viewInsets;
  WindowPadding _viewInsets = ui.WindowPadding.zero as WindowPadding;
}

现在我们可以看到 viewInsets 最终的计算逻辑。那么计算方法 computeOnScreenKeyboardInsets 是在什么情况下调用呢,我们继续追溯,

class FlutterViewEmbedder {
  void reset() {
    if (domWindow.visualViewport != null) {
      _resizeSubscription = DomSubscription(domWindow.visualViewport!, 'resize',
          allowInterop(_metricsDidChange));
    } else {
      _resizeSubscription = DomSubscription(domWindow, 'resize',
          allowInterop(_metricsDidChange));
    }
  }

  void _metricsDidChange(DomEvent? event) {
    updateSemanticsScreenProperties();
    if (isMobile && !window.isRotation() && textEditing.isEditing) {
      window.computeOnScreenKeyboardInsets(true);
      EnginePlatformDispatcher.instance.invokeOnMetricsChanged();
    } else {
      window.computePhysicalSize();
      // When physical size changes this value has to be recalculated.
      window.computeOnScreenKeyboardInsets(false);
      EnginePlatformDispatcher.instance.invokeOnMetricsChanged();
    }
  }
}

这里可以看到在应用初始化的时候监听了 domWindow.visualViewport 或者 domWindowresize 事件,在事件处理函数中调用了 window.computeOnScreenKeyboardInsets 来更新 viewInsets ,然后调用了 EnginePlatformDispatcher.instance.invokeOnMetricsChanged() 来触发了 _MediaQueryFromWindowState 组件的更新。那为什么我们的 html 控件聚焦弹出键盘会造成页面不正常呢,我们可以看到 _metricsDidChange 方法中有一个关键判断条件 textEditing.isEditing,不出所料的话大概率是因为我们自己嵌入的 html 控件聚焦后没有触发该值的更新导致重新计算页面尺寸中的判断错误。我们继续追溯一下 textEditing.isEdting 的值应该怎么修改,

/// Text editing singleton.
final HybridTextEditing textEditing = HybridTextEditing();

class HybridTextEditing {
  void _startEditing() {
    assert(!isEditing);
    isEditing = true;
    strategy.enable(
      configuration!,
      onChange: (EditingState? editingState, TextEditingDeltaState? editingDeltaState) {
        if (configuration!.enableDeltaModel) {
          channel.updateEditingStateWithDelta(_clientId, editingDeltaState);
        } else {
          channel.updateEditingState(_clientId, editingState);
        }
      },
      onAction: (String? inputAction) {
        channel.performAction(_clientId, inputAction);
      },
    );
  }
}

可以看到 textEditing 是一个全局变量,且其实例中有一个方法 startEditing 会修改 isEditing 的值。我们继续追溯 _startEditing 的调用,

/// Responds to the 'TextInput.show' message.
class TextInputShow extends TextInputCommand {
  const TextInputShow();

  @override
  void run(HybridTextEditing textEditing) {
    if (!textEditing.isEditing) {
      textEditing._startEditing();
    }
  }
}

这里 TextInputShow 继承自 TextInputCommand,应该是使用 channel 来调用的,我们继续追溯,

class TextEditingChannel {
  void handleTextInput(
      ByteData? data, ui.PlatformMessageResponseCallback? callback) {
    const JSONMethodCodec codec = JSONMethodCodec();
    final MethodCall call = codec.decodeMethodCall(data);
    final TextInputCommand command;
    switch (call.method) {
      case 'TextInput.show':
        command = const TextInputShow();
        break;
    }
  }
}

可以看到 TextInputShow 命令是通过 TextEditingChannel.handleTextInput 方法触发的,继续追溯,

/// Platform event dispatcher.
///
/// This is the central entry point for platform messages and configuration
/// events from the platform.
class EnginePlatformDispatcher extends ui.PlatformDispatcher {
  void _sendPlatformMessage(
    String name,
    ByteData? data,
    ui.PlatformMessageResponseCallback? callback,
  ) {
    switch (name) {
      case 'flutter/textinput':
        textEditing.channel.handleTextInput(data, callback);
        return;
    }
  }
}

最后到了 EnginePlatformDispatcher 这里。根据注释可以看出这是 platform messages 的中心点。调用流程是客户端使用 SystemChannels.textInput 调用 invokeMethod 方法,然后 Flutter engine 判断 message 中的 name,匹配后调用了相关的命令(这里是 TextInputShow 命令)。Flutter 的 TextInput 控件中在点击的时候应该有一行代码是这样的,

SystemChannels.textInput.invokeMethod('TextInput.show');

到此所有逻辑已经理清了,接下来我们只需要在 html 输入控件获得焦点之前调用一下上面这行代码就能保证键盘弹出后页面适应新尺寸了,特别注意这里说的获得焦点之前,一定要在获得焦点之前调用,因为有可能在获得焦点后调用的话,键盘在 textEditing.isEditing 被修改之前就已经弹出,并且页面也已经更新了,所以要在获得焦点之前调用。所以这里应该在 html 控件的 onTouchStartCapture 中调用。

我尝试过在 HtmlElementView 控件外面包了一层 GestureDetector,打算在 onTapDown 中调用 textInputTextInput.show 命令,可是这里的 onTapDown 事件并不会触发,这里暂时没有找到好的解决方案。然后我用了开头说的方法二,就是自己计算出 viewInsets 并重新给到 MediaQuery 中。下面写了一个组件

import 'dart:async';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html';

import 'package:flutter/widgets.dart';

class CorrectViewInsets extends StatefulWidget {
  const CorrectViewInsets({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

  @override
  State<CorrectViewInsets> createState() => _CorrectViewInsetsState();
}

class _CorrectViewInsetsState extends State<CorrectViewInsets> {
  double _viewInsetsBottom = 0;

  _onResize(Event e) {
    _calcViewInsets();
  }

  @override
  void initState() {
    super.initState();
    window.visualViewport?.addEventListener('resize', _onResize, true);
  }

  @override
  void dispose() {
    window.visualViewport?.removeEventListener('resize', _onResize, true);
    super.dispose();
  }

  Timer? _timer;

  _calcViewInsets() {
    _timer ??= Timer.periodic(const Duration(milliseconds: 100), (Timer timer) {
      final windowInnerHeight = window.innerHeight!.toDouble();
      final visualHeight = window.visualViewport!.height!.toDouble();

      setState(() {
        _viewInsetsBottom = windowInnerHeight - visualHeight;
      });

      if (timer.tick >= 10) {
        _timer?.cancel();
        _timer = null;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);

    return MediaQuery(
      data: mediaQuery.copyWith(
        viewInsets: mediaQuery.viewInsets.copyWith(
          bottom: _viewInsetsBottom,
        ),
      ),
      child: widget.child,
    );
  }
}

只需要在 Scaffold 外面用这个组件包裹起来就能使 Scaffold 得到正确的 viewInsets 并正确的调整页面大小了

@override
Widget build(context) {
  return CorrectViewInsets(
    child: CupertinoPageScaffold()
  );
}

以上解决了页面大小适应问题,还有另一个问题是页面整体上移。下面我们来尝试解决。

在原生 html 控件获得焦点弹出键盘后避免页面整体上移

之前我们说过如果原生 html 输入控件的位置在键盘区域获得焦点弹出键盘的话会造成整体页面向上滚动,这里我们通过 iPhone 的 safari 和 MAC 的 safari 调试来检查元素,看到键盘弹出后整个页面高度始终都是屏幕高度,包括键盘的位置。那我们应该如何避免页面上移呢?

这里页面上移大概率是 document.documentElement.scrollTop 的值变了,即页面向上滚动了。前面说过页面向上滚动的原因是为了保持输入焦点在视野范围内,这是浏览器行为,我们无从干预。尝试了寻找 Flutter 的 TextInput 控件的解决方案,暂时没找到实现代码。

那我们不妨换个思路,既然是页面向上滚动了,那我们就在它滚动后再把它滚动下来不就行了吗。在哪里重置滚动呢?一开始我按常规思路,在 window 上面监听 scroll 事件,只要一触发 scroll 事件我就重置 documentElement.scrollTop 高的为 0,这是正确的方式。也有另外一种方式就是在我们上面的 CorrectViewInsets 组件的更新 viewInsets 逻辑中重置滚动条,

_calcViewInsets() {
  _timer ??= Timer.periodic(const Duration(milliseconds: 100), (Timer timer) {
    // 重置滚动条
    document.documentElement?.scrollTop = 0;

    final windowInnerHeight = window.innerHeight!.toDouble();
    final visualHeight = window.visualViewport!.height!.toDouble();

    setState(() {
      _viewInsetsBottom = windowInnerHeight - visualHeight;
    });

    if (timer.tick >= 10) {
      _timer?.cancel();
      _timer = null;
    }
  });
}

这里使用定时器每隔 100 毫秒重新计算一次,是因为在 resize 事件触发后键盘弹出是一个渐进的过程,可能获得不正确的屏幕尺寸。

这个解决方案是有缺陷的,因为键盘弹出时页面上移速度很快,我们重置了 scrollTop 后页面才会恢复位置,所以页面会有一个上移后再下移的过程。目前暂时没找到好的解决办法。

问题 2 - iframe 获得焦点弹出键盘后 iframe 内部滚动会造成 Flutter 页面上移

在解决了 HtmlElementView 嵌入的 html 控件获得焦点弹出键盘导致 Flutter 页面上移的问题后,发现嵌入的 html 控件的滚动事件会向上传递到浏览器导致 Flutter 页面上移。这里的解决方法比较简单,只需要在 html 控件的根元素上面设置样式 overscroll-behaviorcontain 即可解决,具体文档请查阅 overscroll-behavior