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.
    Surf
    Мобильные приложения и цифровая трансформация

    Похожие публикации

    Комментарии 8

      +5

      Так MediaQuery.of(context) все равно ж вызовет build, если значение изменится. Проще тогда создать виджет-обертку, который будет прокидывать это значение. При желании, можно и в стрим завернуть.


      Но главное не это, делать выводы об открытой/закрытой клавиатуре на основании viewInsets – очень плохая идея. Во-первых, viewInsets определяет "the parts of the display that are completely obscured by system UI, typically by the device's keyboard", т.е. это может быть и не клавиатура в общем случае – получаем ложно-положительное срабатывание. Может быть и ложно-отрицательное – попробуйте в iOS сделать floating keyboard, клавиатура будет на экране, а viewInsets.bottom будет 0.

        0
        Надо будет рассмотреть вариант с iOS. Спасибо.)

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

        Вопросы не по теме, но раз вы работаете с этим, может ответите:
        Собирает ли google данные телеметрии с приложений которые работают на flutter?
        hello world на flutter по прежнем занимает 5Mb?
        Появилась ли возможность собрать desktop приложение?
        Есть ли проблемы с тормозами при срабатывании garbage collector?

          0
          «Собирает ли google данные телеметрии с приложений которые работают на flutter?»

          Вопрос не очень понятен. Что именно они собирать могут?

          «hello world на flutter по прежнем занимает 5Mb?»

          Давно не смотрел, но точно не меньше.

          «Появилась ли возможность собрать desktop приложение?»

          Да, пока далеко не в стабильном канале, но есть.

          «Есть ли проблемы с тормозами при срабатывании garbage collector?»

          Нет.
          0
          Спасибо за статью, интересно было почитать.
          У меня в проекте тоже плотно используется keyboard_visibility. Почему то работает :)
          Flutter doctor
          [✓] Flutter (Channel stable, 1.22.4, on macOS 11.0.1 20B29 darwin-x64, locale en-RU)
          • Flutter version 1.22.4 at /Users/macbook/development/flutter
          • Framework revision 1aafb3a8b9 (7 days ago), 2020-11-13 09:59:28 -0800
          • Engine revision 2c956a31c0
          • Dart version 2.10.4

          [!] Android toolchain — develop for Android devices (Android SDK version 30.0.2)
          • Android SDK at /Users/macbook/Library/Android/sdk
          • Platform android-30, build-tools 30.0.2
          • Java binary at: /Applications/Android Studio 4.2 Preview.app/Contents/jre/jdk/Contents/Home/bin/java
          • Java version OpenJDK Runtime Environment (build 11.0.8+10-b944.6842174)
          ✗ Android license status unknown.
          Run `flutter doctor --android-licenses` to accept the SDK licenses.
          See flutter.dev/docs/get-started/install/macos#android-setup for more details.

          [✓] Xcode — develop for iOS and macOS (Xcode 12.1)
          • Xcode at /Applications/Xcode.app/Contents/Developer
          • Xcode 12.1, Build version 12A7403
          • CocoaPods version 1.9.3
            0

            На момент разработки решения он отвалился.)

            +1
            Вы немного ошиблись с удалением слушателей
            В статье и в репозитории
            Код
             void removeAtChangeListener(String id) {
               _removeAtListener(_changeListeners, id);
             }
            
             void removeAtShowListener(String id) {
               _removeAtListener(_changeListeners, id);
             }
            
             void removeAtHideListener(String id) {
               _removeAtListener(_changeListeners, id);
             }

              0
              Спасибо что заметили.
              Это жертва копипасты.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое