Недавно команда Whitespectre React Native создала новое решение для управления несколькими модальными окнами в React Native, чтобы снизить сложность приложения и улучшить пользовательский опыт.

Во время исследования сборки приложения мы обнаружили, что в условиях текущей стандартной реализации модальных окон в React Native невозможно управлять несколькими модальными окнами, представленными одновременно. Не помогли нам и популярные сторонние библиотеки, также имеющие аналогичное ограничение. И чем сложнее приложение на React Native, тем больше встречается ограничительных условий. Хуже того, ошибки могут оставаться незамеченными.

Поэтому мы разработали собственный подход к решению данной проблемы и запустили библиотеку rn-modal-presenter, которая позволяет представлять несколько модальных окон поверх контента приложения. 

Ознакомиться с полной документацией по библиотеке rn-modal-presenter можно на npm и GitHub.

Ограничения сторонних библиотек модальных окон

Как уже было сказано выше, стандартная реализация модальных окон в React Native, играющих важнейшую роль в улучшении пользовательского опыта, имеет ряд существенных ограничений. Мы обнаружили это, когда попытались показать пользователям несколько модальных окон при различных обстоятельствах.

Уточним: модальные окна — компоненты, которые показываются/скрываются на основе свойства, которое обычно контролируется состоянием представляющего компонента. Компонент должен быть частью дерева компонентов и отображаться как дочерний компонент любого представляющего его компонента. Учитывая это, фундаментальные ограничения и сложности возникают, когда необходимо показать несколько модальных окон, представленных одним компонентом, или когда нужно перейти на отдельный экран с представленным модальным окном.

При попытке сделать это возникают следующие сложности:

  1. Вам необходимо управлять состоянием, которое контролирует каждое из представленных модальных окон.
  1. Обновляя состояние, вы должны управлять представлением и отклонением модальных окон в одном и том же месте (представляющем компоненте).
  1. Если захотите вернуться назад после представления модального окна из компонента, то из-за размонтирования представляющего компонента модальное окно также исчезнет.
  1. На iOS невозможно представить более одного модального окна одновременно или соединить несколько модальных окон, показанных друг на друге: https://github.com/react-native-modal/react-native-modal/issues/30.

Что касается последнего пункта, то ограничение возникает на стороне iOS Native. Если UIViewController представляет другой View Controller поверх него, то представляющий View Controller (который скрыт в данный момент) не может представить еще один второй View Controller поверх этого. Поэтому требуется показывать второй из уже представленного View Controller.

Краткий пример

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

После выполнения пользователем соответствующей задачи в приложении покажем ему:

  1. Модальное окно с уведомлением о том, что задача выполнена.
  1. Модальное окно с вопросом о том, насколько пользователь доволен приложением.
  • Если ответ положительный, покажем модальное окно с предложением оценить приложение в AppStore.
  • Если ответ отрицательный, покажем модальное окно с просьбой оставить отзыв.

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

const App = () => {
 const [showRateAppModal, setShowRateAppModal] = useState(false);
 const [showAppRatedPositiveModal, setShowAppRatedPositiveModal] = useState(false);
 const [showAppRatedNegativeModal, setShowAppRatedNegativeModal] = useState(false);
 const [showActivateGadgetModal, setShowActivateGadgetModal] = useState(false);
 
 return (
   <SafeAreaView>
     {showActivateGadgetModal && (
       <ActivateGadgetModal
         onDismiss={() => {
           setShowActivateGadgetModal(false);
         }}
       />
     )}
     {showRateAppModal && (
       <RateAppModal
         positiveFeedback={() => {
           setShowRateAppModal(false);
           setShowAppRatedPositiveModal(true);
         }}
         negativeFeedback={() => {
           setShowRateAppModal(false);
           setShowAppRatedNegativeModal(true);
         }}
       />
     )}
     {showAppRatedPositiveModal && (
       <PositiveFeedbackModal
         onDismiss={() => setShowAppRatedPositiveModal(false)}
       />
     )}
     {showAppRatedNegativeModal && (
       <NegativeFeedbackModal
         onDismiss={() => setShowAppRatedNegativeModal(false)}
       />
     )}
     <Button
       title="Activate Gadget"
       onPress={() => {
         setShowActivateGadgetModal(true);
         setShowRateAppModal(true);
       }}
     />
   </SafeAreaView>
 );
};

Это довольно простое взаимодействие: мы управляем состояниями из одного и того же компонента и не имеем хранилища. Тем не менее нам все-таки приходится управлять хранилищем с 4 переменными состояния, которое будет усложняться по мере его пополнения.

Теперь посмотрим, что произойдет, если по ошибке попытаться представить два модальных окна одновременно.

В этом случае первое модальное окно будет показано только на iOS, а, что самое неприятное, в консоли React Native никакой ошибки не будет.

Однако при просмотре консоли в Xcode выявится попытка показа View Controller поверх другого View Controller, что не является правильным поведением в контексте системы iOS.

Ошибка, которую увидим в этом случае, выглядит следующим образом:

Warning: Attempt to present <UIViewController: 0x147d2c6b0> on <UIViewController: 0x147d614c0> which is already presenting (null)

Правильным потоком для iOS было бы показать первое модальное окно, а затем, когда первое модальное окно будет отменено (путем установки соответствующей переменной состояния в false), установить переменную состояния следующего модального окна в true, чтобы показать его.

Как понимаете, этот процесс может оказаться очень запутанным, и во многих случаях разработчики используют задержки или тайм-ауты для управления отклонением и показом нескольких модальных окон. Это приводит к неожиданным сбоям, а на практике длительность анимации модального окна может варьироваться в зависимости от устройства, на котором работает приложение.

Подводя итог, можно сказать: проблема заключается в том, что представляющий компонент должен обрабатывать всю логику и управлять состоянием, а нам нужна одна переменная состояния на каждое модальное окно, которое необходимо показать.

А самая серьезная загвоздка состоит в том, что при совершении одной-единственной ошибки, например при попытке показать два модальных окна одновременно, второе окно не появится, и не будет никаких ошибок/предупреждений. 

Тестирование альтернативных решений

Две общие проблемы, с которыми можно столкнуться при управлении несколькими модальными окнами в проекте React Native:

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

Столкнувшись с этими двумя проблемами при использовании Standard React Native Modal Component, мы попробовали различные варианты решения этой проблемы.

Приведем 2 наиболее широко используемые библиотеки и объясним, почему мы, в конце концов, решили пойти своим путем.

react-native-modal

Это расширение Standard React Native Modal Component предоставляет дополнительные возможности, такие как определение входа/выхода времени анимации, предоставление других обратных вызовов для настройки API, а также функции пролистывания, прокрутки и адаптации контента к ориентации устройства.

Однако, как бы ни были широки возможности этой библиотеки, они не меняют принципа работы модальных окон в React Native. Поэтому остаются вышеупомянутые ограничительные условия, например, в плане одновременного представления нескольких модальных окон или снижения сложности кода.

react-native-modalfy

Несмотря на то, что эта библиотека менее популярна, чем предыдущая, ее возможности более отвечают нашим потребностям.

Первое ее преимущество заключается в том, что она поддерживает множество модальных окон, поскольку реализована на JavaScript. Вы можете вызвать функцию JavaScript из любой точки кода, не смешивая дерево компонентов и не управляя дополнительными состояниями.

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

Несмотря на перечисленные преимущества, для настройки библиотеки react-native-modalfy требуется стереотипный код. В этом смысле она очень похожа на React Navigation, где нужно заранее определить маршруты для регистрации каждого модального окна, имеющегося в проекте, и предоставить конфигурации для каждого из них.

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

Наше решение: rn-modal-presenter

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

Гибкость компонента и императивный API

Гибкость в том смысле, что можно представить любой компонент в виде модального окна и управлять представлением/отклонением из любой точки кода (в том числе изнутри представленного модального окна), не изменяя состояние.

Императивный API позволит запускать представления модальных окон из любого места, поскольку модальные окна представляются поверх всего приложения, а не поверх конкретного экрана/компонента.

Представление контента поверх родительского компонента (обычно вашего компонента)

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

Поддержка нескольких модальных окон

Поскольку это на 100 % JavaScript-библиотека, показ нескольких модальных окон поддерживается «из коробки». Наша библиотека также поддерживает показ нескольких экземпляров одного и того же типа модального окна.

Однако JavaScript-решение накладывает и небольшое ограничение: если в проекте есть другие модальные окна Native, то они будут поверх наших, поскольку компоненты Native имеют наивысший приоритет.

Как интегрировать библиотеку

Шаг 1-й. Добавьте библиотеку в свой проект:

  • yarn add @whitespectre/rn-modal-presenter;
  • npm install @whitespectre/rn-modal-presenter.

Шаг 2-й. Оберните компонент, поверх которого хотите представить модальные окна:

import { ModalPresenterParent, showModal } from '@whitespectre/rn-modal-presenter';
…
<ModalPresenterParent>
  <App />
</ModalPresenterParent>

Шаг 3-й. Вызовите метод showModal для получения:

  • компонента, который будет показан;
  • свойств, которые будут отправлены этому компоненту;

    Он возвратит ModalHandler, который вы будете использовать, чтобы закрыть модальное окно позже.
export declare const showModal: <ContentProps>(
  Content: (props: ContentProps & ModalContentProps) => JSX.Element,
  contentProps: ContentProps,
) => ModalHandler;

Вот и все. Вы готовы к работе.

Более сложные реализации

Передача свойств компоненту

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

В данном случае можно создать вспомогательную функцию, которая будет вызывать функцию showModal, получая свойства пользовательского текстового модального окна (то есть текста для показа), а затем обработчик завершения, который будет выполняться при нажатии пользователем кнопки «Close» («Закрыть»).

export const showCustomAlert = (
title: string,
body: string,
buttons: CustomAlertButton[] = [defaultButton],
) => {
return showModal(CustomAlert, { title, body, buttons });
};

Добавление ModalContentProps в свойства компонента

Эта функциональность предоставляется библиотекой при установке компонента и включает в себя функцию dismiss.

const CustomAlert = ({
  dismiss,
  title,
  body,
  buttons
}: CustomAlertProps & ModalContentProps) => {
  return (
    …

Здесь передается функция dismiss, которая возвращается свойствами компонента модального окна и используется для очистки модели из самой модели.

Оригинальный пример использования новой библиотеки

Поскольку библиотека rn-modal-presenter не требует управления состояниями и позволяет запускать представления модальных окон императивно из любого места, наш исходный пример можно переписать следующим образом.

Главный App-компонент будет просто представлять модальное окно ActivateGadgetModal:

const App = () => {
  return (
    <ModalPresenterParent>
      <SafeAreaView>
        <Button
          title="Activate Gadget"
          onPress={() => {
            showModal(ActivateGadgetModal, {});
          }}
        />
      </SafeAreaView>
    </ModalPresenterParent>
  );
};

А затем каждое модальное окно будет отвечать за свое завершение и инициировать представление следующего модального окна в потоке. Например, ActivateGadgetModal будет выглядеть следующим образом:

const ActivateGadgetModal = ({dismiss}: ModalContentProps) => {
return (
<View style={styles.modalOverlay}>
<View style={styles.modal}>
<View style={styles.contentContainer}>
<Text>Your Gadget has been activated</Text>
<View style={styles.buttonsContainer}>
<Button
title="Close"
onPress={() => {
dismiss();
showModal(RateAppModal, {});
}}
/>
</View>
</View>
</View>
</View>
);
};

Планируемые усовершенствования

Библиотека rn-modal-presenter была создана для удовлетворения наших потребностей в текущих проектах. Однако в ходе ее эксплуатации мы выявили другие возможности, которые могут быть полезны для иных случаев использования.

Вот основные оптимизации, которые мы собираемся внести в нашу библиотеку:

  • Создать очередь модальных окон для поочередного показа (сейчас, если несколько модальных окон должны быть показаны одновременно, они появляются друг на друге). 
  • Создать механизм приоритетности в очереди, чтобы определенное модальное окно было показано следующим.
  • Сделать так, чтобы представления появлялись поверх нативных представлений. 
  • Создать нативный модуль, который будет показывать контент поверх нативных представлений.
  • Разрешить настраиваемую анимацию с определением длительности для каждого эффекта.

Если хотите реализовать какие-либо из этих функций или просто внести свой вклад в библиотеку, можете открывать свои PR на: https://github.com/whitespectre/rn-modal-presenter.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Whitespectre: Supporting Multiple Modals in React Native: A New Approach

Предыдущая статьяПакеты NPM: что это такое, откуда они взялись и когда их использовать
Следующая статьяRuby: unless против if