Русский
Русский
English
Статистика
Реклама

Flutter app development

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

19.11.2020 14:19:12 | Автор: admin
Всем привет! Меня зовут Дмитрий Андриянов, я 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@overridevoid 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@overridevoid 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. });}@overridevoid 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@overridevoid 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;

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

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

@overridevoid 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.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru