Flutter. Слушатель клавиатуры без платформенного кода

от автора

Всем привет! Меня зовут Дмитрий Андриянов, я Flutter-разработчик в Surf.

В предыдущей статье про RenderObject я рассказал, как немного копнул в слой рендеринга и смог получать расположение и размеры любого виджета — даже динамического. Сегодня расскажу, как был написан слушатель появления/скрытия клавиатуры без нативного кода.



Эта статья будет вам полезна, если вы:

  • Пишете на Flutter и хотите узнать, что находится у него под капотом.
  • Интересуетесь, как MediaQuery предоставляет данные о UI.
  • Хотите реализовывать интересные штуки на Flutter, покопавшись в нём на более глубоком уровне.

Зачем нам понадобилось написать слушатель без натива

В одном Flutter приложении нам нужно было отлавливать появление и скрытие клавиатуры — мы делали это с помощью плагина keyboard_visibility. Но в апреле, после очередного обновления Flutter, он сломался, потому что команда разработки не переехала на новую реализацию нативной интеграции. Прочие популярные решения из pub также завязаны на нативную часть, а повторно наступать на те же грабли не хотелось.

Мы решили разобраться, можно ли слушать клавиатуру силами Flutter. Чтобы не вносить много правок в существующий код, при разработке решения желательно было сохранить похожий на keyboard_visibility интерфейс.

Исследуем MediaQuery и копаем вглубь

Из MediaQuery мы можем получить данные о размерах системных UI-элементов, которые перекрывают дисплей:

// Поле с данными элементов перекрывающих дисплей MediaQuery.of(context).viewInsets  // Отвечает за данные клавиатуры MediaQuery.of(context).viewInsets.bottom 

Пример:

class KeyboardScreen extends StatefulWidget {  @override  _KeyboardScreenState createState() => _KeyboardScreenState(); }  class _KeyboardScreenState extends State<KeyboardScreen> {  @override  Widget build(BuildContext context) {    return Scaffold(      body: Column(        mainAxisAlignment: MainAxisAlignment.center,        children: [          Text('Keyboard: ${MediaQuery.of(context).viewInsets.bottom}'),          const SizedBox(height: 20),          TextField(),        ],      ),    );  } } 

image

Первая мысль — использовать MediaQuery.of(context).viewInsets при изменениях значения: 0 — клавиатура скрыта, иначе — видна. Но в момент обращения к MediaQueryData мы получим значение, а не Stream, который нужно слушать.

У этого решения две проблемы:

  1. Для использования требуется контекст, что накладывает дополнительные ограничения. Например когда у вас есть модель данных связанная с UI, реагирующая на появление клавиатуры.
  2. viewInsets не дает возможности подписаться на изменения значения.

Нужно что-то более надежное. Мы знаем, что можем получить размер клавиатуры в viewInsets.bottom — и это значение меняется динамически, в зависимости от её появления. Значит, где-то есть механизм, который слушает эти изменения.

Переходим в исходный код метода MediaQueryData of и видим:

 static MediaQueryData of(BuildContext context, { bool nullOk = false }) {  assert(context != null);  assert(nullOk != null);  final MediaQuery query = context.dependOnInheritedWidgetOfExactType<MediaQuery>();  if (query != null)    return query.data;  if (nullOk)    return null;  throw FlutterError.fromParts(<DiagnosticsNode>[    ErrorSummary('MediaQuery.of() called with a context that does not contain a MediaQuery.'),    ErrorDescription(    ),    context.describeElement('The context used was')  ]); } 

final MediaQuery query = context.dependOnInheritedWidgetOfExactType<MediaQuery>();

В этой строке по дереву родителей ищется класс MediaQuery. У полученного виджета берутся и возвращаются данные в виде экземпляра MediaQueryData.

Смотрим в MediaQuery: оказывается, это наследник InheritedWidget, и он создаётся в разных виджетах:

image

В каждом из этих файлов создаётся свой MediaQuery, который получает данные родительского MediaQuery и модифицирует их на свое усмотрение.

Например, файл dialog:

MediaQuery(  data: MediaQuery.of(context).copyWith(    // iOS does not shrink dialog content below a 1.0 scale factor    textScaleFactor: math.max(textScaleFactor, 1.0),  ), 

Самый верхний MediaQuery создаётся в файле widgets/app.dart.
Класс _MediaQueryFromWindow:

 class _MediaQueryFromWindow extends StatefulWidget {  const _MediaQueryFromWindow({Key key, this.child}) : super(key: key);   final Widget child;   @override  _MediaQueryFromWindowsState createState() => _MediaQueryFromWindowsState(); }  class _MediaQueryFromWindowsState extends State<_MediaQueryFromWindow> with WidgetsBindingObserver {  @override  void initState() {    super.initState();    WidgetsBinding.instance.addObserver(this);  }  // ACCESSIBILITY  @override void didChangeAccessibilityFeatures() {  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.  }); }  // METRICS  @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 void didChangeTextScaleFactor() {  setState(() {    // The textScaleFactor property of window has changed. We reference    // window in our build function, so we need to call setState(), but    // we don't need to cache anything locally.  }); }  // RENDERING @override void didChangePlatformBrightness() {  setState(() {    // The platformBrightness property of window has changed. We reference    // window in our build function, so we need to call setState(), but    // we don't need to 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,    );  }   @override  void dispose() {    WidgetsBinding.instance.removeObserver(this);    super.dispose();  } } 

Что здесь происходит:

1. Класс _MediaQueryFromWindowsState замешивает миксин WidgetsBindingObserver, чтобы использоваться в качестве наблюдателя за изменениями системного UI из Flutter.

2. В initState вызываем WidgetsBinding.instance.addObserver(this); — addObserver принимает на вход экземпляр наблюдателя. В данном случае this, так как текущий класс замешивает WidgetsBindingObserver.

3. WidgetsBindingObserver предоставляет методы, которые вызываются при изменении соответствующих метрик:
didChangeAccessibilityFeatures — вызывается при изменении набора активных на данный момент специальных возможностей в системе.
didChangeMetrics — вызывается при изменении размеров приложения из-за системы. Например, при повороте телефона или влиянии системного UI (появлении клавиатуры).
didChangeTextScaleFactor — вызывается при изменении коэффициента масштабирования текста на платформе.
didChangePlatformBrightness — вызывается при изменении яркости.

4. Самое главное, что объединяет эти методы, — в каждом из них вызывается setState. Это запускает метод build, заново строит объект MediaQueryData

Widget build(BuildContext context) {  MediaQueryData data = MediaQueryData.fromWindow(WidgetsBinding.instance.window); 

и передает его вниз по дереву до места вызова MediaQuery.of(context).ИмяПоля:

Подробнее про биндинг можно прочесть в статье моего коллеги Миши Зотьева.

Вывод: мы можем получать изменения системного UI, используя WidgetsBinding и WidgetsBindingObserver.

Реализация слушателя клавиатуры

Начнём реализовывать слушатель клавиатуры на основе этих данных. Для начала создадим класс:

class KeyboardListener with WidgetsBindingObserver {}

Добавим геттер bool — чтобы знать, видна ли клавиатура.

Во время его реализации я столкнулся с одной проблемой. Изначально запоминался текущий размер клавиатуры, чтобы внешний код мог получить его у экземпляра слушателя.

 double get currentKeyboardHeight => _currentKeyboardHeight;  double _currentKeyboardHeight = 0;  bool get _isVisibleKeyboard => _currentKeyboardHeight > 0;  Future(() {  final double newKeyboardHeight =      WidgetsBinding.instance.window.viewInsets.bottom;   if (newKeyboardHeight > _currentKeyboardHeight) {    /// Новая высота больше предыдущей — клавиатура открылась    _onShow();    _onChange(true);  } else if (newKeyboardHeight < _currentKeyboardHeight) {    /// Новая высота меньше предыдущей — клавиатура закрылась    _onHide();    _onChange(false);  }   _currentKeyboardHeight = newKeyboardHeight; }); 

Мы знаем, что при видимой клавиатуре в viewInsets.bottom значение больше 0, при скрытой — 0.

bool get _isVisibleKeyboard => _currentKeyboardHeight > 0; выполняет проверку: если высота клавиатуры больше нуля, то она видна.

Но на некоторых устройствах с Android 9 при закрытии клавиатуры высота не всегда становилась 0. Открытая клавиатура могла передать значение 400, а закрытая — 150. А в следующий раз она передавала уже 0. Нестабильный и сложно уловимый баг.

Поэтому я решил отказаться от возможности получать размер клавиатуры из экземпляра слушателя и стал проверять:

WidgetsBinding.instance.window.viewInsets.bottom > 0;

Это решило проблему.

Теперь реализуем непосредственно прослушивание изменений для вызова колбэков:

@override void didChangeMetrics() {  _listener(); }  void _listener() {  if (isVisibleKeyboard) {    _onChange(true);  } else {    _onChange(false);  } }  void _onChange(bool isOpen) {  /// Тут вызываются внешние слушатели } 

Как и говорилось выше, благодаря didChangeMetrics мы знаем, что изменился системный UI. И проверяя, видна ли клавиатура, вызываем колбеки появления/сокрытия клавиатуры.

Как использовать

 class _KeyboardScreenState extends State<KeyboardScreen> {  bool _isShowKeyboard = false;   KeyboardListener _keyboardListener = KeyboardListener();   @override  void initState() {    super.initState();    _keyboardListener.addListener(onChange: (bool isVisible) {      setState(() {        _isShowKeyboard = isVisible;      });    });  }   @override  void dispose() {    _keyboardListener.dispose();    super.dispose();  }   @override  Widget build(BuildContext context) {    return Scaffold(      body: Column(        mainAxisAlignment: MainAxisAlignment.center,        children: [          Text('Keyboard: $_isShowKeyboard'),          const SizedBox(height: 20),          TextField(),        ],      ),    );  } } 

image

Полный код

 import 'dart:math'; import 'dart:ui';  import 'package:flutter/widgets.dart';  typedef KeyboardChangeListener = Function(bool isVisible);  class KeyboardListener with WidgetsBindingObserver {  static final Random _random = Random();    /// Колбэки, вызывающиеся при появлении и сокрытии клавиатуры  final Map<String, KeyboardChangeListener> _changeListeners = {};  /// Колбэки, вызывающиеся при появлении клавиатуры  final Map<String, VoidCallback> _showListeners = {};  /// Колбэки, вызывающиеся при сокрытии клавиатуры  final Map<String, VoidCallback> _hideListeners = {};   bool get isVisibleKeyboard =>      WidgetsBinding.instance.window.viewInsets.bottom > 0;   KeyboardListener() {    _init();  }     void dispose() {    // Удаляем текущий класс из списка наблюдателей    WidgetsBinding.instance.removeObserver(this);     // Очищаем списки колбэков    _changeListeners.clear();    _showListeners.clear();    _hideListeners.clear();  }    /// При изменениях системного UI вызываем слушателей  @override  void didChangeMetrics() {    _listener();  }    /// Метод добавления слушателей  String addListener({    String id,    KeyboardChangeListener onChange,    VoidCallback onShow,    VoidCallback onHide,  }) {    assert(onChange != null || onShow != null || onHide != null);    /// Для более удобного доступа к слушателям используются идентификаторы    id ??= _generateId();     if (onChange != null) _changeListeners[id] = onChange;    if (onShow != null) _showListeners[id] = onShow;    if (onHide != null) _hideListeners[id] = onHide;    return id;  }   /// Методы удаления слушателей  void removeChangeListener(KeyboardChangeListener listener) {    _removeListener(_changeListeners, listener);  }   void removeShowListener(VoidCallback listener) {    _removeListener(_showListeners, listener);  }   void removeHideListener(VoidCallback listener) {    _removeListener(_hideListeners, listener);  }   void removeAtChangeListener(String id) {    _removeAtListener(_changeListeners, id);  }   void removeAtShowListener(String id) {    _removeAtListener(_changeListeners, id);  }   void removeAtHideListener(String id) {    _removeAtListener(_changeListeners, id);  }   void _removeAtListener(Map<String, Function> listeners, String id) {    listeners.remove(id);  }   void _removeListener(Map<String, Function> listeners, Function listener) {    listeners.removeWhere((key, value) => value == listener);  }   String _generateId() {    return _random.nextDouble().toString();  }   void _init() {    WidgetsBinding.instance.addObserver(this); // Регистрируем наблюдателя  }   void _listener() {    if (isVisibleKeyboard) {      _onShow();      _onChange(true);    } else {      _onHide();      _onChange(false);    }  }   void _onChange(bool isOpen) {    for (KeyboardChangeListener listener in _changeListeners.values) {      listener(isOpen);    }  }   void _onShow() {    for (VoidCallback listener in _showListeners.values) {      listener();    }  }   void _onHide() {    for (VoidCallback listener in _hideListeners.values) {      listener();    }  } } 

Можно было реализовать только _changeListeners или всего один колбэк. Но перед нами стояла задача сохранить API в проекте, который уверенно двигался к релизу. Поэтому использование нового слушателя должно было принести минимум правок.

Итог

Мы в очередной раз увидели, что решить проблемы и реализовать интересные штуки можно без нативной реализации. Достаточно копнуть чуть глубже или просто изучить механизм работы того или иного виджета.

Это решение находится в SurfGear, пакет keyboard_listener.

ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/528710/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *