Всем привет. Меня зовут Дмитрий Андриянов. Два года я писал на React Native, сейчас работаю в Surf во Flutter отделе и уже более полутора лет пишу на Flutter.

В первой части статьи я рассказал про основные различия между React Native и Flutter.
В этой части расскажу про различия между React Native и Flutter при создании UI для Android и iOS.



Эта статья для тех, кто:

  • Не знает, какое кросс-платформенное решение выбрать, и хочет увидеть разницу между подходами к созданию интерфейса.
  • Интересуется, будут ли различия в поведении UI в зависимости от технологии.
  • Пишет на React Native и хочет узнать, как создавать UI на Flutter.
  • Flutter разработчикам, интересующимся React Native и желающим сравнить создание UI.
  • Тем, кому не хватает для сравнения практических примеров.

Ниже покажу разницу между UI на примере экрана приложения на Android и iOS. В примерах затронем вёрстку, анимации. Расскажу, что делать с тяжёлыми операциями, чтобы разгрузить поток.

Все примеры на Flutter и React Native работают в debug-режиме, что не отражает реальную производительность в продакшн.

Создание экрана


Создадим экран на React Native и Flutter.

Основные элементы:

  • Сжимающаяся шапка.
  • Заголовок и кнопки, которые находятся в шапке.
  • Скролящийся список мест.

Начнём с сжимающейся шапки.

React Native


Из коробки ничего нет, но на выбор есть 3 варианта:

  1. Библиотека, предоставляющая базовые возможности. Мы ограничены, тем что можем использовать только текст, мало контроля за расположением элементов.
  2. Библиотека, которая оборачивает нативный компонент Android. Варианта для iOS нет: это не кроссплатформенное решение.
  3. Код, написанный вручную.

Выберем третий вариант.

const HEADER_EXPANDED_HEIGHT = 200
const HEADER_COLLAPSED_HEIGHT = 60
const { width: SCREEN_WIDTH } = Dimensions.get('screen')
 
 
export default function CitiesScreen() {
 
   const screenNames = [
       'Главная',
       'Галерея',
   ];
 
   const [scrollY, setScrollY] = useState(new Animated.Value(0))
 
   let headerHeight = scrollY.interpolate({
       inputRange: [0, HEADER_EXPANDED_HEIGHT - HEADER_COLLAPSED_HEIGHT],
       outputRange: [HEADER_EXPANDED_HEIGHT, HEADER_COLLAPSED_HEIGHT],
       extrapolate: 'clamp'
   });
 
   let heroTitleOpacity = scrollY.interpolate({
       inputRange: [0, HEADER_EXPANDED_HEIGHT - HEADER_COLLAPSED_HEIGHT],
       outputRange: [1, 0],
       extrapolate: 'clamp'
   });
 
 
   return (
       <View style={styles.container}>
           <SafeAreaView>
               <View>
                   <View style={styles.scrollWrapper}>
                       <ScrollView
                           contentContainerStyle={{
                               paddingTop: HEADER_EXPANDED_HEIGHT,
                               backgroundColor: 'rgba(0,0,0,.1)'
                           }}
                           onScroll={({
                               nativeEvent: {
                                   contentOffset: { x, y },
                               }
                           }) => {
                               scrollY.setValue(y);
                           }}
                           scrollEventThrottle={16}
                       >
 
                           <View style={{ width: '100%', height: 5000 }}>
 
                           </View>
 
                       </ScrollView>
                   </View>
                   <Animated.View style={[
                       styles.header,
                       { height: headerHeight }
                   ]} >
                       <Text style={[styles.titleCollapsed]}>
                           Title
                       </Text>
                       <Animated.View
                           style={[styles.expandedView, { opacity: heroTitleOpacity }]}
                        >
                           {screenNames.map((name) => (
                               <View
                                   key='name'
                                   style={{ marginHorizontal: 5 }}
                               >
                                   <Button
                                       style={{
                                           borderRadius: 8,
                                           paddingVertical: 5,
                                           paddingHorizontal: 20,
 
                                       }}
                                       onPress={() => { }}
                                       title={name}
                                       color="white"
                                   />
                               </View>
                           ))}
                       </Animated.View>
                   </Animated.View>
 
               </View>
           </SafeAreaView>
       </View>
   );
 
 
}
 
const styles = StyleSheet.create({
   container: {
       backgroundColor: 'black'
   },
   scrollWrapper: {
       backgroundColor: 'white'
   },
   scrollContainer: {
       padding: 16
   },
   header: {
       width: SCREEN_WIDTH,
       position: 'absolute',
       top: 0,
       left: 0,
       backgroundColor: 'rgba(0,0,0,1)'
   },
   titleCollapsed: {
       textAlign: 'center',
       marginTop: 28,
       color: 'white'
   },
   expandedView: {
       position: 'absolute',
       bottom: 16,
       left: 16,
       flexDirection: 'row'
   }
})



Повторим тот же UI на Flutter


class CitiesScreen extends StatefulWidget {
 @override
 _CitiesScreenState createState() => _CitiesScreenState();
}

class _CitiesScreenState extends State<CitiesScreen> {
 final List<String> screenNames = [
   'Главная',
   'Галерея',
 ];

 @override
 Widget build(BuildContext context) {
   return NestedScrollView(
     headerSliverBuilder: (context, _) {
       return [
         SliverAppBar(
           backgroundColor: Colors.black.withOpacity(.9),
           title: Text('Title'),
           pinned: true,
           centerTitle: true,
           expandedHeight: 200,
           flexibleSpace: FlexibleSpaceBar(
             collapseMode: CollapseMode.pin,
             background: Align(
               alignment: Alignment.bottomLeft,
               child: Container(
                 height: 50,
                 padding: const EdgeInsets.only(bottom: 10),
                 child: Row(
                   mainAxisSize: MainAxisSize.min,
                   mainAxisAlignment: MainAxisAlignment.start,
                   children: [
                     for (String name in screenNames)
                       Padding(
                         padding: const EdgeInsets.symmetric(horizontal: 5),
                         child: RaisedButton(
                           onPressed: () {},
                           color: Colors.white,
                           shape: RoundedRectangleBorder(
                             borderRadius: BorderRadius.circular(8),
                           ),
                           child: Padding(
                             padding: const EdgeInsets.symmetric(
                               vertical: 5,
                               horizontal: 20,
                             ),
                             child: Center(
                               child: Text(name),
                             ),
                           ),
                         ),
                       ),
                   ],
                 ),
               ),
             ),
           ),
         ),
       ];
     },
     body: Container(
       height: 5000,
       color: Colors.black.withOpacity(.1),
     ),
   );
 }
}



Получилось схожее поведение, но в React Native есть проблема с кнопками:



Это можно исправить, установив библиотеку или написав код самому. Но решение из коробки не работает.

Также в React Native пришлось самому писать реализацию, чтобы создать более-менее усложнённую шапку. На Flutter я обошёлся исключительно SDK.

Если понадобится, чтобы AppBar скроллился вместе с контентом, то:

  • В React Native нужно переписывать код.
  • Во Flutter — поставить pinned false.



Для резкого сворачивания:

  • В RN — дорабатывать взаимодействие скролла и анимации.
  • Во Flutter — snap true.



Чтобы шапка не менялась через opacity, а съезжала:

  • RN — снова доработки.
  • Flutter — вместо background использовать title.


Теперь добавим контент.

Добавим cities.json с городами со следующей структурой данных:

[
 	{
 	 	"id": 1,
 	 	"name": String,
 	 	"image": String,
        },
]

React Native


Заменим ScrollView на FlatList и выведем список городов.

<FlatList
    contentContainerStyle={{
        paddingTop: HEADER_EXPANDED_HEIGHT,
        backgroundColor: 'white',
    }}
    onScroll={({
        nativeEvent: {
            contentOffset: { x, y },
        }
    }) => {
        scrollY.setValue(y);
    }}
    scrollEventThrottle={16}
    data={cities}
    renderItem={({ item }) => {
        return (
            <View style={{
                padding: 10,
            }}>
                <Image
                    style={{
                        resizeMode: 'contain',
                        flex: 1,
                        aspectRatio: 1,
                        borderRadius: 4,
                        width: isGrid ? '69%' : 'auto'
                    }}
                    source={{
                        uri: item.image,
                    }}
                />
                <Text
                    style={[
                        {
                            marginTop: 5,
                            marginBottom: 10,
                            fontSize: 24,
                        }
                    ]}
                 >
                    {item.name}
                </Text>
            </View>
        );
    }}
    keyExtractor={item => item.id}
>
 
</FlatList>

Добавим Switch, переключающий вывод контента списком или гридом:

const [isGrid, setGrid] = useState(false);

<Switch
    value={isGrid}
    onValueChange={() => setGrid(isGrid => !isGrid)}
    trackColor={{ false: "grey", true: "white" }}
    thumbColor={isGrid ? "white" : "#f4f3f4"}
/>

Добавим вывод грида:

<FlatList
    numColumns={isGrid ? 2 : 1}

<Text
    style={[
        {
            marginTop: 5,
            marginBottom: 10,
            fontSize: 24,
                                                      
        },
        isGrid ? {
            position: 'absolute',
            top: 5,
            left: 10
        } : null
    ]}
>
    {item.name}
</Text>


При переключении получаем ошибку:



Оказалось, FlatList на лету не умеет менять количество колонок.

Укажем ключ, чтобы под капотом это стали разные компоненты:

<FlatList
    key = {isGrid ? 'Grid' : 'List'}
    numColumns={isGrid ? 2 : 1}



Автоматически место равномерно не распределилось. Поэтому допишем вёрстку:

<View style={{
    padding: 10,
}}>
    <Image
        style={{
            resizeMode: 'contain',
            flex: 1,
            aspectRatio: 1,
            borderRadius: 4,
            width: isGrid ? '69%' : 'auto'
        }}
        source={{
            uri: item.image,
        }}
    />
    <Text
        tyle={[
            {
                marginTop: 5,
                marginBottom: 10,
                fontSize: 24,
                                                      
            },
            isGrid ? {
                position: 'absolute',
                top: 5,
                left: 10
            } : null
        ]}
    >
        {item.name}
    </Text>
</View>

Полагаться на % хоть и спорное решение, но рабочее. В реальном приложении лучше потратить время на вёрстку и написать по-другому.

Добавим лоадер:

const [isLoading, setLoading] = useState(true);
 
   useEffect(() => {
       setTimeout(() => setLoading(false), 1000);
   }, []);

isLoading ?
    <View style={{
        width: '100%',
        height: '100%',
        justifyContent: 'center',
        alignItems: 'center'
    }}>

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

В нём можно работать с данными и вызывать императивный API.

Запустим код на Android:


iOS


Напишем то же самое на Flutter


Switch:

SliverAppBar(
 backgroundColor: Colors.black.withOpacity(.9),
 title: Row(
   mainAxisAlignment: MainAxisAlignment.spaceBetween,
   children: [
     const SizedBox.shrink(),
     Text('Title'),
     Switch(
       value: _isGrid,
       onChanged: (bool value) {
         setState(() {
           _isGrid = value;
         });
       },
     ),
   ],
 ),

body: FutureBuilder(
 future: _getCities(),
 builder: _buildList,
),

Widget _buildList(context, snapshot) {
   if (snapshot.data == null) {
     return Center(
       child: CircularProgressIndicator(),
     );
   }
   final cities = snapshot.data;
   if (_isGrid) {
     return GridView.builder(
       gridDelegate:
           SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
       itemBuilder: (context, int index) {
         return Padding(
           padding: const EdgeInsets.all(10),
           child: Stack(
             children: [
               ClipRRect(
                 borderRadius: BorderRadius.circular(4.0),
                 child: Image.network(
                   cities[index].image,
                   width: double.infinity,
                   fit: BoxFit.fill,
                 ),
               ),
               Padding(
                 padding: const EdgeInsets.only(top: 5, bottom: 10),
                 child: Text(
                   cities[index].name,
                   style: TextStyle(fontSize: 24),
                 ),
               )
             ],
           ),
         );
       },
     );
   }
   return ListView.builder(
     itemCount: cities.length,
     itemBuilder: (context, int index) {
       return Padding(
         padding: const EdgeInsets.all(10),
         child: Column(
           mainAxisSize: MainAxisSize.min,
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             ClipRRect(
               borderRadius: BorderRadius.circular(4.0),
               child: Image.network(
                 cities[index].image,
                 width: double.infinity,
                 fit: BoxFit.fill,
               ),
             ),
             Padding(
               padding: const EdgeInsets.only(top: 5, bottom: 10),
               child: Text(
                 cities[index].name,
                 style: TextStyle(fontSize: 24),
               ),
             )
           ],
         ),
       );
     },
   );
 }

 Future<List<City>> _getCities() async {
   String json =
       await DefaultAssetBundle.of(context).loadString('res/cities.json');

   List<dynamic> data = jsonDecode(json);
   List<City> result = [];
   data.forEach((city) {
     result.add(City(
       id: city['id'],
       name: city['name'],
       image: city['image'],
       description: city['description'],
       places: city['places'],
     ));
   });
   return result;
 }
}

class City {
 final int id;
 final String name;
 final String image;
 final String description;
 final List<dynamic> places;

 City({
   this.id,
   this.name,
   this.image,
   this.description,
   this.places,
 });
}

Запустим Flutter:


iOS


В итоге по созданию экрана


React Native

  1. Получаем нативное, но неконтролируемое поведение на разных платформах, что при едином дизайне для обеих платформ будет только мешать.
  2. Из-за OEM виджетов мы не можем влиять на физику прокрутки. С одной стороны, это гарантирует нативное поведение, с другой — у нас меньше возможностей, и мы не можем выбрать физику под задачу.
  3. Чтобы отобразить контент в Grid-стиле, нужно произвести дополнительные работы.
  4. Для построения кроссплатформенного UI нужно писать с нуля компонент или устанавливать библиотеку.
  5. Некоторых компонентов просто нет из коробки, а библиотеки предоставляют ограниченную функциональность.
  6. Так как использование ключа создаёт новый новый FlatList на месте предыдущего, компоненты с карточками городов в FlatList полностью пересоздаётся с нуля.

Также хочется добавить, что пока писал шапку, тестировал её на iOS. Запустил на Android — приложение падает, а в логах пусто.


Это может починиться перезагрузкой компьютера — а может и нет. Бывало, у меня всё исправлялось «само», и причина была неясна. Такие неопределённости влияют на сроки.

Если у вас есть свободное время, можно найти проблему и решить её. Но быстрее всего пересоздать проект — что я и сделал. Это тоже отнимает время, но меньше, чем потребуется для поиска и решения проблемы.

Flutter

  1. Есть различные виджеты, позволяющие писать сложный UI.
  2. Экран реализован исключительно силами фреймворка, поэтому не пришлось дописывать логику.
  3. Подходит для более сложных и нестандартных кейсов.
  4. Как мы видим, Flutter рендерит на Android и iOS одинаковые виджеты.

Внешний вид во Flutter не отдаётся на откуп системе, а зависит от решения разработчика. Так, в этом примере мы используем виджеты из Material-библиотеки. Они не нативные для iOS, но во Flutter можно использовать виджеты без привязки к платформе: это не связывает руки при создании требуемого UI.

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

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

Пользователь и заказчик не будут против, если вы покажете ему не Container с
тенью, а Material Card с elevation — если внешний вид соответствует дизайну.

Если необходимо, вы всегда можете использовать родные виджеты на каждой из платформ.
Вы сами решаете, а Flutter даёт вам главное — контроль. Это экономит время, а значит, и затраты.

Кастомизация UI


Flutter не связан ограничениями OEM виджетов: если нужно сверстать что-то нестандартное, можно взять существующий виджет и настроить его.

На React Native тоже можно сделать кастомные виджеты, но зачастую придётся писать их с нуля, используя View. При использовании готовых виджетов мы будем завязаны на поведение платформы и требуемые параметры для нативных виджетов.

Возьмём для примера компонент Alert диалог.

React Native


Alert.alert(
    "Alert Title",
    "My Alert Msg",
    [
        {
            text: "Cancel",
            onPress: () => console.log("Cancel Pressed"),
            style: "cancel"
        },
        { text: "OK", onPress: () => console.log("OK Pressed") }
    ],
    { cancelable: false }
);

В итоге всё, что мы можем настроить, — это задать текст и показать кнопки. Это ограничения OEM виджета и, следовательно, React Native API.



Flutter


showDialog(
 context: context,
 builder: (context) {
   return AlertDialog(
     title: Text('Title Dialog'),
     actions: [
       FlatButton(
         onPressed: () {},
         child: Text('Ok'),
       ),
     ],
   );
 },
);



Как и ожидалось, в React Native платформа сама решила, как ей отобразить диалог. Во Flutter же мы получили ровно то, что написали.

Теперь сделаем свой виджет, базируясь на AlertDialog:

showDialog(
 context: context,
 builder: (context) {
   return AlertDialog(
     backgroundColor: Colors.white.withOpacity(.9),
     shape: BeveledRectangleBorder(
       borderRadius: BorderRadius.all(
         Radius.circular(20),
       ),
     ),
     title: Text(
       'Благодарим за то что пользуетесь нашим приложением',
       textAlign: TextAlign.center,
     ),
     content: SizedBox(
       height: 150,
       child: Icon(
         Icons.tag_faces,
         size: 100,
         color: Colors.orange,
       ),
     ),
     actions: [
       FlatButton(
         onPressed: () {},
         child: Text('Закрыть'),
       ),
     ],
   );
 },
);



Мы написали виджет, который отличается от исходного. За основу взяли логику и внешний вид Alert:

  • Модальный роут.
  • Карточка.
  • Ряд кнопок снизу.

Если вдруг вам кажется кощунственным править «нативный» Alert, можно просто использовать модальный роут диалога и написать внутри свой виджет:

showDialog(
 context: context,
 builder: (context) {
   return Material(
     type: MaterialType.transparency,
     child: Center(
       child: Container(
         padding: const EdgeInsets.all(10),
         decoration: BoxDecoration(
           color: Colors.white,
           shape: BoxShape.circle,
         ),
         child: Column(
           mainAxisSize: MainAxisSize.min,
           children: [
             FlatButton(
               child: Text('ok'),
               onPressed: () {},
             ),
           ],
         ),
       ),
     ),
   );
 },
);


Если React Native говорит: «Вот тебе компоненты платформы — ты можешь использовать их в таких-то кейсах», то Flutter говорит: «Вот тебе разные виджеты, настроенные под определенное поведение, но можешь кастомизировать как хочешь».
Как мы видим, утверждение «один код на обе платформы» правдиво для обеих технологий. Но запущенный код — ещё не готовое приложение. Нужно также запустить один UI на разных платформах. React Native не способен выполнить эту задачу в полной мере и с минимальными усилиями из-за использования OEM виджетов.

Получается, что React Native — это кроссплатформенная логика и частично кроссплатформенный UI.

Анимация


Добавим анимацию в наши экраны. Рассмотрим только API из коробки.

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

React Native


let windowWidth = Dimensions.get('screen').width;
 
function runAnim(anim, toValue, reverseValue) {
    Animated.timing(
        anim,
       {
            toValue,
            duration: 1000,
       }
    ).start(
        () => runAnim(anim, reverseValue, toValue)
   )
}
 
const AnimatedLoader: () => ReactNode = () => {
 
    let loaderWidth = 100;
    let maxLeft = windowWidth - loaderWidth;
 
    const leftAnim = useRef(new Animated.Value(0)).current
    const fadeAnim = leftAnim.interpolate({
        inputRange: [0, maxLeft],
        outputRange: [.8, 1]
    });
 
    useEffect(() => {
        runAnim(leftAnim, maxLeft, 0);
    }, [])
 
    return (
        <Animated.View style={{
            width: loaderWidth,
            height: 5,
            backgroundColor: 'white',
            position: 'absolute',
            left: leftAnim,
            opacity: fadeAnim,
            top: 70,
        }} />
    )
 
}

Поместим его над View со стилем expandedView.

<AnimatedLoader/>
    <Animated.View style={[styles.expandedView, { opacity: heroTitleOpacity }]}>



Таким способом пишутся анимации в React Native. Если нужно связать значения, чтобы одно анимировалось в зависимости от другого, используется Interpolate. Можно использовать флаг useNativeDriver, который запускает анимацию в обход моста и повышает её производительность, — но только при анимации определённых свойств.

Пытаюсь включить флаг useNativeDriver для моей анимации и получаю ошибку:



Flutter


Во Flutter из коробки есть два пути анимации: явная и неявная.

Напишем лоадер, используя явную анимацию.

class AnimationExplicitLoader extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => _AnimationExplicitLoaderState();
}

class _AnimationExplicitLoaderState extends State<AnimationExplicitLoader>
   with SingleTickerProviderStateMixin {
 final double _loaderWidth = 100;

 AnimationController _controller;

 Animation<double> _offsetAnimation;
 Animation<double> _opacityAnimation;

 @override
 void initState() {
   super.initState();

   final double maxLeft =
       (window.physicalSize / window.devicePixelRatio).width;

   _controller = AnimationController(
     duration: const Duration(seconds: 1),
     vsync: this,
   )..repeat(reverse: true);

   _offsetAnimation = Tween<double>(
     begin: 0,
     end: maxLeft - _loaderWidth,
   ).animate(_controller);
   _opacityAnimation = Tween<double>(
     begin: .8,
     end: 1,
   ).animate(_controller);
 }

 @override
 void dispose() {
   _controller.dispose();
   super.dispose();
 }

 @override
 Widget build(BuildContext context) {
   return AnimatedBuilder(
     animation: _controller,
     builder: _buildAnimatedWidget,
   );
 }

 Widget _buildAnimatedWidget(BuildContext context, Widget child) {
   return Opacity(
     opacity: _opacityAnimation.value,
     child: Transform.translate(
       offset: Offset(_offsetAnimation.value, 0),
       child: Opacity(
         opacity: _opacityAnimation.value,
         child: Container(
           width: _loaderWidth,
           height: 5,
           color: Colors.white,
         ),
       ),
     ),
   );
 }
}

Вставим лоадер в AppBar.

SliverAppBar(
 titleSpacing: 0,
 backgroundColor: Colors.black.withOpacity(.9),
 title: Column(
   crossAxisAlignment: CrossAxisAlignment.start,
   mainAxisSize: MainAxisSize.min,
   children: [
     Row(
       mainAxisAlignment: MainAxisAlignment.spaceBetween,
       children: [
         const SizedBox.shrink(),
         Text('Title'),
         Switch(
           value: _isGrid,
           onChanged: (bool value) {
             setState(() {
               _isGrid = value;
             });
           },
         ),
       ],
     ),
     AnimationExplicitLoader(),
   ],
 ),



В примере выше контроллер анимации за время Duration увеличивает значение анимации от 0 до 1.

Animation — класс, интерполирующий значения контроллера на нужные значения.

repeat(reverse: true) — зацикливает анимацию.

Анимация может работать, используя setState в слушателе при готовности нового кадра анимации, но это лишний раз перестраивает поддерево.

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

Размеры экрана нам нужны в initState, но в нём нельзя использовать поиск родительского виджета через контекст. Поэтому вместо MediaQuery.of(context).size.width рассчитаем размер экрана сами. Альтернативой было бы использовать MediaQuery, но не забудьте создать контроллер и присвоить переменной в первом вызове build.

@override
Widget build(BuildContext context) {
 if (_controller == null) {
   _initAnimation();
 }
 return AnimatedBuilder(
   animation: _controller,
   builder: _buildAnimatedWidget,
 );
}

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

class AnimationImplicitLoader extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => _AnimationImplicitLoaderState();
}

class _AnimationImplicitLoaderState extends State<AnimationImplicitLoader> {
 static const _duration = Duration(seconds: 1);

 final double _loaderWidth = 100;

 double _maxLeftCached;

 double get _maxLeft =>
     _maxLeftCached ??= MediaQuery.of(context).size.width - _loaderWidth;

 bool _isStart = false;

 @override
 void initState() {
   super.initState();
   SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
     setState(() => _isStart = true);
   });
 }

 @override
 Widget build(BuildContext context) {
   return SizedBox(
     height: 5,
     child: Stack(
       children: [
         AnimatedPositioned(
           duration: _duration,
           left: _isStart ? 0 : _maxLeft,
           onEnd: _onEndAnimation,
           child: AnimatedOpacity(
             duration: _duration,
             opacity: _isStart ? .8 : 1,
             child: Container(
               width: _loaderWidth,
               height: 5,
               color: Colors.white,
             ),
           ),
         ),
       ],
     ),
   );
 }

 void _onEndAnimation() {
   setState(() {
     _isStart = !_isStart;
   });
 }
}

И используем получившийся виджет AnimationImplicitLoader вместо AnimationExplicitLoader.

Виджеты неявной анимации анимируют разницу между новыми и старыми параметрами. Но при первом построении анимация не запускается. Чтобы запустить анимацию, в initState после первого фрейма запускаем setState.

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

Альтернатива биндингу — Future (аналог Promise из JavaScript).

Future.delayed(_duration).then(
 (value) => setState(() => _isStart = true),
);

Вместо AnimatedPositioned, который должен использоваться в Stack и быть обёрнутым в родителя с ограничением размеров (в данном случае — SizedBox), можно использовать другой виджет. Например, SlideTransition — но для его использования уже нужен контроллер.

В примере использовались AnimatedPositioned и AnimatedOpacity. Но во Flutter множество готовых анимаций, вплоть до анимации перехода между двумя виджетами.

Значение анимаций во Flutter можно менять на ходу.

Добавим для примера такую возможность при нажатии на полоску:

Tween<double> _opacityTween;

@override
void initState() {
 super.initState();

 final double maxLeft =
     (window.physicalSize / window.devicePixelRatio).width;

 _controller = AnimationController(
   duration: const Duration(seconds: 1),
   vsync: this,
 )..repeat(reverse: true);

 _offsetAnimation = Tween<double>(
   begin: 0,
   end: maxLeft - _loaderWidth,
 ).animate(_controller);

 _opacityTween = Tween<double>(
   begin: .8,
   end: 1,
 );
 _opacityAnimation = _opacityTween.animate(_controller);
}

@override
void dispose() {
 _controller.dispose();
 super.dispose();
}

@override
Widget build(BuildContext context) {
 return GestureDetector(
   onTap: () {
     _opacityTween.begin = 0;
   },
   child: AnimatedBuilder(
     animation: _controller,
     builder: _buildAnimatedWidget,
   ),
 );
}



По итогу мы имеем:
В React Native из коробки есть только явная анимация. Во Flutter можно использовать явную анимацию, дающую полный контроль, и неявную, которая работает просто при смене входящих параметров в анимационый виджет SDK. Какой фреймворк выбрать, зависит от того, нужен ли вам контроль вплоть до динамического изменения значений.

Hero анимация


В React Native нужно писать её самому. Если вам захочется изменить анимационный эффект, придётся переписывать решение. Также можно использовать библиотеку.

Во Flutter же это доступно на уровне SDK. Правда, не всё так гладко. Сложные анимации могут подтормаживать при первом запуске. Чтобы этого избежать придётся делать дополнительный прогрев. Во Flutter 1.22 анимации работают эффективнее, и в ряде случаев прогрев уже не требуется.

Один процесс — один поток


В React Native и Flutter код запускается в одном главном процессе с одним потоком.
Весь парсинг и вычисления выполняются в нём.

Напишем в нашем React Native приложении эмуляцию получения данных:

async function getData() {
    let count = await new Promise((resolve) => {
        setTimeout(() => resolve(1000000000), 1000);
    })
 
    new Promise(function (resolve) {
        let n = 0;
        for (let i = 0; i < count; i++) {
            n += i;
        }
        resolve(n);
    })
}

Используем в компоненте:

useEffect(() => {
    setTimeout(() => {
    setLoading(false)
    getData();
    }, 3000);
}, []);


Приложение запущено, и поначалу взаимодействие с UI нормальное. Нажимаем на кнопку — видим тестовый Alert. JS поток выдаёт в среднем 55 fps.

Перезапускаем приложение, и при первом рендере UI запускается тяжёлый цикл: UI более недоступен, JS поток просел полностью. Скроллить список мы можем: это поведение OEM виджета на уровне платформы, и JS поток на него не влияет. Но кнопки на нажатия не реагируют, потому что сигнал сначала идет в JS поток, оттуда в мост, а оттуда на кнопку. А поскольку JS поток занят циклом, он не может выполнять ничего другого.

Для выполнения фоновых задач можно рассмотреть HeadlessJS, но он только для Android, а посему не универсален.

В данном случае вариантом будет разбить на мелкие асинхронные операции:

async function getData() {
   let count = await new Promise((resolve) => {
       setTimeout(() => resolve(1000000000), 1000);
   })
 
   let n = 0;
   for(let i = 0; i < count; i++) {
       n += await new Promise((res) => {
           setTimeout(() => {
               res(i);
           }, 1);
       })
   }
   resolve(n);
}

Каждый шаг цикла обёрнут в Promise, а значит, выполнится позже и разгрузит поток на данный момент.



Напишем то же самое на Flutter.

Добавим Alert на кнопку:

for (String name in screenNames)
 Padding(
   padding: const EdgeInsets.symmetric(horizontal: 5),
   child: RaisedButton(
     onPressed: () {
       showDialog(
         context: context,
         builder: (_) {
           return AlertDialog(
             title: Text('Тестовый Alert'),
             actions: [
               FlatButton(
                 child: Text('OK'),
                 onPressed: () {
                   Navigator.of(context).pop();
                 },
               )
             ],
           );
         },
       );
     },
     color: Colors.white,
     shape: RoundedRectangleBorder(
       borderRadius: BorderRadius.circular(8),
     ),
     child: Padding(
       padding: const EdgeInsets.symmetric(
         vertical: 5,
         horizontal: 20,
       ),
       child: Center(
         child: Text(name),
       ),
     ),
   ),
 ),

И напишем получение данных:

void getData() async {
 final int count = await Future.delayed(const Duration(seconds: 1)).then(
   (_) => 1000000000,
 );

 Future(() {
   int n = 0;
   for (int i = 0; i < count; i++) {
     n += i;
   }
   return n;
 });
}

Future<List<City>> _getCities() async {
 getData();
 String json =
     await DefaultAssetBundle.of(context).loadString('res/cities.json');

 List<dynamic> data = jsonDecode(json);
 List<City> result = [];
 data.forEach((city) {
   result.add(City(
     id: city['id'],
     name: city['name'],
     image: city['image'],
     description: city['description'],
     places: city['places'],
   ));
 });
 return result;
}


Мы видим те же проблемы с отзывчивостью UI. Починить их можно так же — через асинхронность:

void getData() async {
 final int count = await Future.delayed(const Duration(seconds: 1)).then(
   (_) => 1000000000,
 );

 Future(() async {
   int n = 0;
   for(int i = 0; i < count; i++) {
     n += await Future(() {
       return i;
     });
   }
   return n;
 });

}

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

JSON для JS — это родные массивы и объекты: он может с ними работать как есть. Для Dart же нужно сперва его распарсить. Это займёт время и ресурсы, а главная проблема в том, что логику не разобьешь на более мелкие асинхронные операции.

Тяжёлая логика, которую не получится разбить, в любом случае заблокирует UI поток. В React Native с этим придётся мириться, в Dart же есть изоляты.

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

Новый изолят становится дочерним по отношению к главному, в котором исполняется UI.

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

Future<List<City>> _getCities() async {
   String json =
       await DefaultAssetBundle.of(context).loadString('res/cities.json');

   List<dynamic> data = jsonDecode(json);
   List<City> result = await compute(_handleData, data);

   return result;
 }
} // Конец виджета

Future<int> getData() async {
 final int count = await Future.delayed(const Duration(seconds: 1)).then(
       (_) => 1000000000,
 );

 int n = 0;
 for (int i = 0; i < count; i++) {
   n += i;
 }
 return n;

}

List<City> _handleData(data) {
 getData();
 List<City> result = [];
 data.forEach((city) {
   result.add(City(
     id: city['id'],
     name: city['name'],
     image: city['image'],
     description: city['description'],
     places: city['places'],
   ));
 });
 return result;
}



compute — упрощённый вариант создания изолята. Создаёт, выполняет, закрывает.

Используя изолят, не нужно стараться разбить операции, оборачивать во Future.
Цикл на скриншоте повесит поток дочернего изолята, не затронув другие.

Можно вызывать compute для редких тяжёлых кейсов или держать постоянно открытыми нужное количество изолятов и передавать им данные для вычислений.

Навигация


React Native


Одна из главных проблем React Native — навигация. SDK не предоставляет приемлемого решения для использования в продакшне.

Официальная документация советует использовать библиотеку от сообщества React Navigation. Её преимущество — простота и беспроблемная интеграция с другими библиотеками. Но логика исполняется в JS потоке.

Хорошая альтернатива — навигация от WIX: React Native Navigation. Её преимущество — то, что навигация исполняется в нативном потоке. Минус — в дополнительной сложности, например, при работе с Redux. Могут возникнуть проблемы со сторонними библиотеками, использующими нативный код.

Более детальное сравнение можно посмотреть в статье «React Navigation vs React Native Navigation: The complete comparison».

В React Native мы имеем два основных решения для создания навигации. Есть другие библиотеки, но эти — самые главные, на мой взгляд.

Выбор зависит от целей:
Нужна простота и минимум сложностей в проекте — React Navigation.
Нужна более высокая производительность — React Native Navigation.

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

Flutter


Во Flutter навигация присутствует из коробки. Не нужно ничего ставить и определять приоритеты. Для Flutter существуют библиотеки по навигации, но они базируются на существующей функциональности, а не создают её с нуля. Но и без них навигация полноценна и успешно используется в проектах.

Варианты использования:

1. Переход на экран и обратно:

onPressed: () {
  Navigator.push(
    context,
    MaterialPageRoute(builder: (context) => SecondRoute()),
  );
}

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

Можно и создать явный экземпляр:

Material

class CitiesScreenRoute extends MaterialPageRoute {
 CitiesScreenRoute()
     : super(
         builder: (ctx) => CitiesScreen(),
       );
}

Cupertino

class CitiesScreenRoute extends CupertinoPageRoute {
 CitiesScreenRoute()
     : super(
         builder: (ctx) => CitiesScreen(),
       );
}

2. Именованный маршрут:

MaterialApp(
  // Start the app with the "/" named route. In this case, the app starts
  // on the FirstScreen widget.
  initialRoute: '/',
  routes: {
    // When navigating to the "/" route, build the FirstScreen widget.
    '/': (context) => FirstScreen(),
    // When navigating to the "/second" route, build the SecondScreen widget.
    '/second': (context) => SecondScreen(),
  },
);

В версии Flutter 1.22 появился новый способ навигации: Navigator 2 с декларативным подходом:

Navigator(
 key: navigatorKey,
 pages: [
   MaterialPage(
     key: ValueKey('BooksListPage'),
     child: BooksListScreen(
       books: books,
       onTapped: _handleBookTapped,
     ),
   ),
   if (show404)
     MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
   else if (_selectedBook != null)
     BookDetailsPage(book: _selectedBook)
 ],
 onPopPage: (route, result) {
   if (!route.didPop(result)) {
     return false;
   }

   // Update the list of pages by setting _selectedBook to null
   _selectedBook = null;
   show404 = false;
   notifyListeners();

   return true;
 },
)

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

Прежнюю императивную навигацию тоже можно использовать без проблем. Подробнее о новой навигации и сравнение со старым способом описано в статье «Learning Flutter’s new navigation and routing system».

Overlay


Overlay — это стек наложения, содержащий виджеты поверх других виджетов. Они управляются независимо друг от друга.

Сам по себе Overlay не перекрывает виджеты при наслоении — они так же без проблем доступны для взаимодействия.

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

Мы можем накладывать нужные виджеты поверх других, строя более сложный UI.

Преимущества использования Overlay:

  • Виджет живёт не в рамках экрана, а в своем отдельном слое.
  • Разметка экрана и виджета в Overlay не влияют друг на друга.
  • Не привязан к конкретному экрану, может располагаться поверх маршрутов.

Каждый из виджетов управляет своим участием в наложении с помощью OverlayEntry.

Navigator.of(context) возвращает экземпляр NavigatorState. Если мы откроем его исходный код, то в методе build увидим:

@override
Widget build(BuildContext context) {
 assert(!_debugLocked);
 assert(_history.isNotEmpty);
 // Hides the HeroControllerScope for the widget subtree so that the other
 // nested navigator underneath will not pick up the hero controller above
 // this level.
 return HeroControllerScope(
   child: Listener(
     onPointerDown: _handlePointerDown,
     onPointerUp: _handlePointerUpOrCancel,
     onPointerCancel: _handlePointerUpOrCancel,
     child: AbsorbPointer(
       absorbing: false, // it's mutated directly by _cancelActivePointers above
       child: FocusScope(
         node: focusScopeNode,
         autofocus: true,
         child: Overlay(
           key: _overlayKey,
           initialEntries: overlay == null ?
               _allRouteOverlayEntries.toList(growable: false)
              : const <OverlayEntry>[],
         ),
       ),
     ),
   ),
 );
}

Нас интересует строка child: Overlay.

Как мы видим, навигатор использует наложение для управления визуальным оформлением своих маршрутов.

Напишем прибитый к низу блок с информацией, используя Overlay:

bool _overlayFlag = true;

OverlayEntry _entry;
Timer timer;

@override
void initState() {
 super.initState();

 timer = Timer.periodic(const Duration(seconds: 3), (timer) {
   _overlayFlag = !_overlayFlag;
   _entry.markNeedsBuild();
 });

 SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
   _entry = OverlayEntry(builder: (context) {
     return Positioned(
       bottom: 10,
       left: 0,
       right: 0,
       child: Material(
         type: MaterialType.transparency,
         child: Center(
           child: AnimatedContainer(
             decoration: BoxDecoration(
               color: _overlayFlag ? Colors.black : Colors.white,
               boxShadow: [
                 BoxShadow(
                   blurRadius: 10,
                   color: _overlayFlag ? Colors.white : Colors.black,
                 ),
               ],
             ),
             duration: const Duration(milliseconds: 500),
             child: Padding(
               padding: const EdgeInsets.symmetric(
                 vertical: 50,
                 horizontal: 100,
               ),
               child: Text(
                 'Плавающая карточка',
                 style: TextStyle(
                   color: _overlayFlag ? Colors.white : Colors.black,
                 ),
               ),
             ),
           ),
         ),
       ),
     );
   });
   Overlay.of(context).insert(_entry);
 });
}

@override
void dispose() {
 _entry?.remove();
 timer?.cancel();
 super.dispose();
}


_entry — переменная экземпляра OverlayEntry.
timer — экземпляр Timer.

В initState создаём экземпляр OverlayEntry. В зависимости от флага _overlayFlag инвертируем цвета.

Overlay.of(context) — находим Overlay, и с помощью нашего метода insert вставляем экземпляр entry в стэк наложения.

Таймер каждые 3 секунды меняет значение _overlayFlag. Для применения изменений используется _entry.markNeedsBuild() — чтобы оповестить entry и чтобы он подтянул новые значения. Так как виджет в стэке управляется назависимо от других, то это касается также того виджета, где он был создан. Смена внешнего State на него не влияет.

Если же вам хочется работать с оверлеями в декларативном стиле, то подойдет эта библиотека.

Вердикт


С точки зрения создания UI, React Native предоставляет платформу с основной функциональностью из коробки, которая позволяет использовать единую кодовую базу на разных системах. Это каркас, на котором можно построить приложение, — но придётся использовать сторонние библиотеки или реализовывать необходимые части вручную.

Из-за специфики архитектуры, которая предполагает использование OEM виджетов, одним из узких мест может оказаться поведение платформы.

Flutter предоставляет полноценный SDK с разнообразными виджетами и возможностью кастомизации. Сторонние библиотеки для создания UI не требуются. Благодаря собственной отрисовке виджетов, Flutter не зависит от операционной системы, на которой он запущен, и предоставляет разработчику контроль над пользовательским интерфейсом.

В следующей части сравним логическую сторону React Native и Flutter: взаимодействие с платформой, инструменты, локализацию.

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

Страница курса по Flutter

Подробная статья о курсе и учебном процессе