Как с помощью Flutter достичь адаптивности приложений

Показываем на примерах Dart-кода, как в Flutter реализовать кроссплатформенный адаптивный дизайн для смартфонов, планшетов и больших экранов 📱💻🖥️

Что такое Flutter?

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

Как включить десктопную и веб-разработку

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

  • установить Flutter, загрузив последнюю версию из архива SDK;
  • если Flutter уже установлен, переключиться на бета-канал с помощью $flutter channel beta, а затем обновить версию Flutter с помощью $flutter upgrade.

Далее запускаем следующее:

Shell
$ flutter config --enable-web

Чтобы включить поддержку десктопной разработки, необходимо переключиться на master-релиз, выполнив описанные выше действия. Запустите следующую команду, заменив <OS_NAME> на linux, windows или macos:

$ flutter config --enable-<OS_NAME>-desktop

При возникновении проблем вот общие рекомендации по устранению:

  • запустите flutter doctor для проверки наличия проблем. Эта команда должна загружать любые необходимые компоненты;
  • запустите flutter upgrade;
  • перезагрузитесь – может быть это именно то, что вам нужно.

Запуск и создание веб-приложений

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

$ flutter devices

должно появиться что-то подобное:

Web Server • web-server • web-javascript • Flutter Tools

Выполнение команды flutter run в совместимом Flutter-проекте приведёт к запуску Flutter веб-сервера на localhost:<RANDOM_PORT> . Это позволит получить доступ к веб-приложению Flutter из любого браузера.

Если у вас установлен Chrome, может потребоваться добавить переменную окружения CHROME_EXECUTABLE в путь к исполняемому файлу Chrome.

Запуск и создание десктопных приложений

После того как вы включили десктоп-поддержку, можно запустить приложение Flutter на своей рабочей станции с помощью flutter run-d <OS_NAME>, заменив <OS_NAME> тем же значением, которое вы использовали при включении поддержки ранее. Также вы можете создавать бинарники в каталоге сборки с помощью flutter build <OS_NAME>.

Прежде чем приступить, нужно иметь каталог, содержащий все необходимое для Flutter и вашей платформы. При создании нового проекта всё будет добавлено автоматически, а для существующего проекта используйте команду flutter create. Linux и Windows API пока нестабильны, поэтому, возможно, придётся заново сгенерировать папки для этих платформ, если приложение перестанет работать после обновления Flutter.

Когда приложение совместимо?

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

Например, вы можете использовать пакет url_launcher или пакет от Google – path_provider, используемый для получения локального пути и сохранения файлов или пакет shared_preferences, для работы с локальным хранилищем HTML в интернете.

Создание адаптивных макетов

Как сделать своё приложение отзывчивым? Для этого нужно ответить на пару вопросов:

  1. Какие виджеты используются и какие из них могут/должны помогать с адаптацией к экранам разных размеров?
  2. Как получить информацию о размере экрана и использовать его при создании UI?

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

  1. Взять информацию из MediaQueryData, которая находится в дереве виджетов. Эту часть (MaterialApp/WidgetsApp/CupertinoApp) можно получить, как и любой другой InheritedWidget, с помощью MediaQuery.of(context). Он имеет свойство size, относящееся к типу Size с двумя свойствами width и height типа double.
  2. Использовать LayoutBuilder (так же, как StreamBuilder или FutureBuilder), передающий функции builder (вместе с контекстом) объекту BoxConstraints со свойствами minHeight, maxHeight, minWidth и maxWidth.

Вот пример MediaQuery для получения размеров:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(context) =>
    MaterialApp(
      home: MyHomePage()
    );
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(context) =>
    Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            Text(
              "Width: ${MediaQuery.of(context).size.width}",
              style: Theme.of(context).textTheme.headline4
            ),
            Text(
              "Height: ${MediaQuery.of(context).size.height}",
              style: Theme.of(context).textTheme.headline4
            )
          ]
       )
     )
   );
}

А вот та же задача, но с использованием LayoutBuilder:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(context) =>
    MaterialApp(
      home: MyHomePage()
    );
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(context) =>
    Scaffold(
      body: LayoutBuilder(
        builder: (context, constraints) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              Text(
                "Width: ${constraints.maxWidth}",
                style: Theme.of(context).textTheme.headline4
              ),
              Text(
                "Height: ${constraints.maxHeight}",
                style: Theme.of(context).textTheme.headline4
              )
            ]
         )
       )
     )
  );
}

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

Наиболее адаптируемый виджет – это GridView. Он построен с использованием GridView.extent constructor и даже не нуждается в нашем участии:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(context) =>
    MaterialApp(
      home: MyHomePage()
    );
}

class MyHomePage extends StatelessWidget {
  final List elements = [
    "Zero",
    "One",
    "Two",
    "Three",
    "Four",
    "Five",
    "Six",
    "Seven",
    "Eight",
    "A Million Billion Trillion",
    "A much, much longer text that will still fit"
  ];


  @override
  Widget build(context) =>
    Scaffold(
      body: GridView.extent(
        maxCrossAxisExtent: 130.0,
        crossAxisSpacing: 20.0,
        mainAxisSpacing: 20.0,
        children: elements.map((el) => Card(child: Center(child: Padding(padding: EdgeInsets.all(8.0), child: Text(el))))).toList()
      )
   );
}

Вы можете разместить содержимое различных размеров, изменив maxCrossAxisExtent.

Этот пример использует GridView.extent GridView. Было бы разумнее применить GridView.builder вместе с SliverGridDelegateWithMaxCrossAxisExtent – в этом случае виджеты, отображаемые в сетке, динамически создаются из другой структуры данных:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(context) =>
    MaterialApp(
      home: MyHomePage()
    );
}

class MyHomePage extends StatelessWidget {
  final List<String> elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];


  @override
  Widget build(context) =>
    Scaffold(
      body: GridView.builder(
        itemCount: elements.length,
        gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
          maxCrossAxisExtent: 130.0,
          crossAxisSpacing: 20.0,
          mainAxisSpacing: 20.0,
        ),
        itemBuilder: (context, i) => Card(
          child: Center(
            child: Padding(
              padding: EdgeInsets.all(8.0), child: Text(elements[i])
            )
          )
        )
      )
   );
}

Примером адаптированного GridView к различным экранам является страница, представляющая собой простое веб-приложение Flutter, состоящее из GridView и Cards.

Заменим Drawer с постоянным меню слева:

class Menu extends StatelessWidget {
  @override
  Widget build(context) => ListView(
    children: [
      FlatButton(
        onPressed: () {},
          child: ListTile(
          leading: Icon(Icons.looks_one),
          title: Text("First Link"),
        )
      ),
      FlatButton(
        onPressed: () {},
          child: ListTile(
          leading: Icon(Icons.looks_two),
          title: Text("Second Link"),
        )
      )
    ]
  );
}

Хранить меню, которое будет находиться внутри Drawer, можно в гамбургер-элементе. Альтернативы: BottomNavigationBar или TabBar в сочетании с TabBarView, но в обоих случаях придётся внести больше изменений, чем требуется для Drawer.

Чтобы показать только Drawer, содержащий меню, которое мы видели ранее на небольших экранах, нужен код, проверяющий ширину с помощью MediaQuery.of(context) и передающий объект Drawer в Scaffold только в том случае, если он меньше некоторого значения ширины:

Scaffold(
    appBar: AppBar(/* ... \*/),
    drawer: MediaQuery.of(context).size.width < 500 ?
    Drawer(
      child: Menu(),
    ) :
    null,
    body: /* ... \*/
)

Теперь давайте поговорим о Scaffold. В качестве примера основного содержимого приложения мы используем всё тот же GridView, который хранится в виджете Content:

class Content extends StatelessWidget {
  final List elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];
  @override
  Widget build(context) => GridView.builder(
    itemCount: elements.length,
    gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
      maxCrossAxisExtent: 130.0,
      crossAxisSpacing: 20.0,
      mainAxisSpacing: 20.0,
    ),
    itemBuilder: (context, i) => Card(
      child: Center(
        child: Padding(
          padding: EdgeInsets.all(8.0), child: Text(elements[i])
        )
      )
    )
  );
}

На больших экранах body может быть строкой, показывающей два виджета: меню фиксированной ширины, и содержимое, заполняющее остальную часть экрана. На маленьких экранах всё тело – это содержимое.

Обернём всё в SafeArea и Center, потому что иногда виджеты веб-приложений Flutter, особенно при использовании строк и столбцов, оказываются за пределами видимой области экрана.

Это означает, что body в Scaffold будет следующим:

SafeArea(
  child:Center(
    child: MediaQuery.of(context).size.width < 500 ? Content() :
    Row(
      children: [
        Container(
          width: 200.0,
          child: Menu()
        ),
        Container(
          width: MediaQuery.of(context).size.width-200.0,
          child: Content()
        )
      ]
    )
  )
)

А вот всё вместе:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(context) => MaterialApp(
    home: HomePage()
  );
}


class HomePage extends StatelessWidget {
  @override
  Widget build(context) => Scaffold(
    appBar: AppBar(title: Text("test")),
    drawer: MediaQuery.of(context).size.width < 500 ? Drawer(
      child: Menu(),
    ) : null,
    body: SafeArea(
        child:Center(
          child: MediaQuery.of(context).size.width < 500 ? Content() :
          Row(
            children: [
              Container(
                width: 200.0,
                child: Menu()
              ),
              Container(
                width: MediaQuery.of(context).size.width-200.0,
                child: Content()
              )
            ]
          )
        )
    )
  );
}

class Menu extends StatelessWidget {
  @override
  Widget build(context) => ListView(
    children: [
      FlatButton(
        onPressed: () {},
          child: ListTile(
          leading: Icon(Icons.looks_one),
          title: Text("First Link"),
        )
      ),
      FlatButton(
        onPressed: () {},
          child: ListTile(
          leading: Icon(Icons.looks_two),
          title: Text("Second Link"),
        )
      )
    ]
  );
}

class Content extends StatelessWidget {
  final List<String> elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];
  @override
  Widget build(context) => GridView.builder(
    itemCount: elements.length,
    gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
      maxCrossAxisExtent: 130.0,
      crossAxisSpacing: 20.0,
      mainAxisSpacing: 20.0,
    ),
    itemBuilder: (context, i) => Card(
      child: Center(
        child: Padding(
          padding: EdgeInsets.all(8.0), child: Text(elements[i])
        )
      )
    )
  );
}

Это основная часть материала, который нам понадобится в качестве введения в адаптивный Flutter-UI. Теперь рассмотрим отзывчивость приложения со стороны общих элементов приложения и UI-потоков.

Создание адаптивной страницы входа

Экраны входа в систему на мобильных устройствах обычно очень похожи друг на друга. Это просто столбец с некоторым Padding вокруг виджетов, текстовыми полями для ввода логина/пароля и кнопкой для входа. Стандартная страница входа в приложение может выглядеть так:

Scaffold(
  body: Container(
    padding: const EdgeInsets.symmetric(
      vertical: 30.0, horizontal: 25.0
    ),
    child: Column(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: [
        Text("Welcome to the app, please log in"),
        TextField(
          decoration: InputDecoration(
            labelText: "username"
          )
        ),
        TextField(
          obscureText: true,
          decoration: InputDecoration(
            labelText: "password"
          )
        ),
        RaisedButton(
          color: Colors.blue,
          child: Text("Log in", style: TextStyle(color: Colors.white)),
          onPressed: () {}
        )
      ]
    ),
  ),
)

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

Например, мы установим максимальную ширину в 500 и указали такие ограничения контейнера. Это приведёт к тому, что поля будут «прилипать» к левой стороне экрана, что ещё хуже. Итак, обернём центральный виджет:

Center(
  child: Container(
    constraints: BoxConstraints(maxWidth: 500),
    padding: const EdgeInsets.symmetric(
      vertical: 30.0, horizontal: 25.0
    ),
    child: Column(/* ... \*/)
  )
)

Выглядит прекрасно, и не пришлось использовать ни LayoutBuilder, ни MediaQuery.of(context).size. Теперь попробуем отделить переднюю часть от фона. Изменяем цвет фона и того, что находится за контейнером, но сохраняем белым передний план контейнера. На больших экранах не будем растягивать контейнер в верхней и нижней части, а придадим ему закруглённые углы и сделаем анимированный переход.

Вот пример такой страницы.

Поведение блока при изменении размеров экрана
class LoginPage extends StatelessWidget {
  @override
  Widget build(context) =>
    Scaffold(
      body: LayoutBuilder(
        builder: (context, constraints) {
          return AnimatedContainer(
            duration: Duration(milliseconds: 500),
            color: Colors.lightGreen[200],
            padding: constraints.maxWidth < 500 ? EdgeInsets.zero : EdgeInsets.all(30.0),
            child: Center(
              child: Container(
                padding: EdgeInsets.symmetric(
                  vertical: 30.0, horizontal: 25.0
                ),
                constraints: BoxConstraints(
                  maxWidth: 500,
                ),
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(5.0),
                ),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.spaceAround,
                  children: [
                    Text("Welcome to the app, please log in"),
                    TextField(
                      decoration: InputDecoration(
                        labelText: "username"
                      )
                    ),
                    TextField(
                      obscureText: true,
                      decoration: InputDecoration(
                        labelText: "password"
                      )
                    ),
                    RaisedButton(
                      color: Colors.blue,
                      child: Text("Log in", style: TextStyle(color: Colors.white)),
                      onPressed: () {
                        Navigator.pushReplacement(
                          context,
                          MaterialPageRoute(
                            builder: (context) => HomePage()
                          )
                        );
                      }  
                    )
                  ]
                ),
              ),
            )
          );
        }
      )
   );
}

Маршрутизация в приложении

Веб-приложения позволяют изменять экраны на основе URL-адреса. Flutter поддерживает такую функциональность. Для этого нам нужно изменить конструктор MaterialApp на следующий:

MaterialApp(
  initialRoute: "/login",
  routes: {
    "/login": (context) => LoginPage(),
    "/home": (context) => HomePage()
  }
);

Теперь мы можем переключиться на другой маршрут с помощью Navigator.pushNamed(context, routeName) и Navigator.pushReplacementNamed(context, routeName) вместо Navigator.push(context, route) и Navigator.pushReplacement(context, route).

Вот остальная часть приложения. В DartPad вы не увидите именованных маршрутов в действии, поэтому попробуйте самостоятельно.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(context) =>
    MaterialApp(
      initialRoute: "/login",
      routes: {
        "/login": (context) => LoginPage(),
        "/home": (context) => HomePage()
      }
    );
}

class LoginPage extends StatelessWidget {
  @override
  Widget build(context) =>
    Scaffold(
      body: LayoutBuilder(
        builder: (context, constraints) {
          return AnimatedContainer(
            duration: Duration(milliseconds: 500),
            color: Colors.lightGreen[200],
            padding: constraints.maxWidth < 500 ? EdgeInsets.zero : const EdgeInsets.all(30.0),
            child: Center(
              child: Container(
                padding: const EdgeInsets.symmetric(
                  vertical: 30.0, horizontal: 25.0
                ),
                constraints: BoxConstraints(
                  maxWidth: 500,
                ),
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(5.0),
                ),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.spaceAround,
                  children: [
                    Text("Welcome to the app, please log in"),
                    TextField(
                      decoration: InputDecoration(
                        labelText: "username"
                      )
                    ),
                    TextField(
                      obscureText: true,
                      decoration: InputDecoration(
                        labelText: "password"
                      )
                    ),
                    RaisedButton(
                      color: Colors.blue,
                      child: Text("Log in", style: TextStyle(color: Colors.white)),
                      onPressed: () {
                        Navigator.pushReplacementNamed(
                          context,
                          "/home"
                        );
                      }
                    )
                  ]
                ),
              ),
            )
          );
        }
      )
   );
}


class HomePage extends StatelessWidget {
  @override
  Widget build(context) => Scaffold(
    appBar: AppBar(title: Text("test")),
    drawer: MediaQuery.of(context).size.width < 500 ? Drawer(
      child: Menu(),
    ) : null,
    body: SafeArea(
        child:Center(
          child: MediaQuery.of(context).size.width < 500 ? Content() :
          Row(
            children: [
              Container(
                width: 200.0,
                child: Menu()
              ),
              Container(
                width: MediaQuery.of(context).size.width-200.0,
                child: Content()
              )
            ]
          )
        )
    )
  );
}

class Menu extends StatelessWidget {
  @override
  Widget build(context) => ListView(
    children: [
      FlatButton(
        onPressed: () {},
          child: ListTile(
          leading: Icon(Icons.looks_one),
          title: Text("First Link"),
        )
      ),
      FlatButton(
        onPressed: () {},
          child: ListTile(
          leading: Icon(Icons.looks_two),
          title: Text("Second Link"),
        )
      ),
      FlatButton(
        onPressed: () {Navigator.pushReplacementNamed(
          context, "/login");},
          child: ListTile(
          leading: Icon(Icons.exit_to_app),
          title: Text("Log Out"),
        )
      )
    ]
  );
}

class Content extends StatelessWidget {
  final List<String> elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];
  @override
  Widget build(context) => GridView.builder(
    itemCount: elements.length,
    gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
      maxCrossAxisExtent: 130.0,
      crossAxisSpacing: 20.0,
      mainAxisSpacing: 20.0,
    ),
    itemBuilder: (context, i) => Card(
      child: Center(
        child: Padding(
          padding: EdgeInsets.all(8.0), child: Text(elements[i])
        )
      )
    )
  );
}

Заключение

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

Вот ещё пара наших заметок о Flutter:

Полезные ресурсы:

  • Desktop shells: всегда актуальная информация по Desktop Flutter
  • Desktop support for Flutter: информация о поддерживаемых настольных платформах.
  • Web support for Flutter: информация о поддерживаемых веб-платформах.
  • All Samples: обновляемый список примеров Flutter приложений.

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ