Кастомная нижняя панель в android

Настраиваемая форма нижней панели в android

Однажды меня попросили создать приложение с необычным дизайном нижней панели BottomNavigationView :

Кастомная нижняя панель в android
Изогнутый BottomNavigationView

Я никогда ранее не делал ничего подобного, поэтому начал гуглить на предмет создания custom view. После поисков и советов коллег, я понял, что это всего лишь разновидность кривой Безье.

Кривая Безье

Если вы когда-либо использовали компьютерные программы для рисования, вы скорее всего использовали кривые Безье, даже не подозревая об этом. 

Кастомная нижняя панель в android
пример кривой Безье в Adobe Illustrator

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

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

Кастомная нижняя панель в android

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

Перейдём к коду и увидим, как создавать кривые Безье в android.

Вначале создадим собственный view, расширяющий BottomNavigationView, чтобы взять весь его функционал и сконцентрироваться только на дизайне.

public class CurvedBottomNavigationView extends BottomNavigationView {
    public CurvedBottomNavigationView(Context context) {
        super(context);
        init();
    }
    public CurvedBottomNavigationView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public CurvedBottomNavigationView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    private void init() {
        mPath = new Path();
        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mPaint.setColor(Color.WHITE);
        setBackgroundColor(Color.TRANSPARENT);
    }
}

Метод init()в этом коде только инициализирует объекты path и paint.

Роль path — позволить нам рисовать геометрические формы, в том числе кривые Безье. Path рисует линию от точки до точки. Обычный 
BottomNavigationView в форме прямоугольника, поэтому используя Path мы перерисуем его с помощью кривых.

Кастомная нижняя панель в android
Позиции точек

Сначала инициализируем позиции наших точек. Они зависят от ширины и высоты view. Здесь P1 — начальная точка рисования, P2 — начало первой кривой, P3 — конец первой кривой и начало второй, а P4 — окончание второй кривой. 

Никогда не делайте расчёты в методе onDraw()— он вызывается постоянно и это просто убьёт производительность приложения. Я делаю расчёты в методе onSizeChanged(), вызываемом при изменении размера view, поэтому на производительности это не должно сильно сказаться

 @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // получаем ширину и высоту navigation bar
         mNavigationBarWidth = getWidth();
         mNavigationBarHeight = getHeight();
        // координаты (x,y) начальной точки до кривой
        mFirstCurveStartPoint.set((mNavigationBarWidth / 2) - (CURVE_CIRCLE_RADIUS * 2) - (CURVE_CIRCLE_RADIUS / 3), 0);
        // координаты (x,y) конечной точки после кривой
        mFirstCurveEndPoint.set(mNavigationBarWidth / 2, CURVE_CIRCLE_RADIUS + (CURVE_CIRCLE_RADIUS / 4));
        // то же самое для второй кривой
        mSecondCurveStartPoint = mFirstCurveEndPoint;
        mSecondCurveEndPoint.set((mNavigationBarWidth / 2) + (CURVE_CIRCLE_RADIUS * 2) + (CURVE_CIRCLE_RADIUS / 3), 0);

        // координаты (x,y) первой опорной точки на кубической кривой
        mFirstCurveControlPoint1.set(mFirstCurveStartPoint.x + CURVE_CIRCLE_RADIUS + (CURVE_CIRCLE_RADIUS / 4), mFirstCurveStartPoint.y);
        // координаты (x,y) второй опорной точки
        mFirstCurveControlPoint2.set(mFirstCurveEndPoint.x - (CURVE_CIRCLE_RADIUS * 2) + CURVE_CIRCLE_RADIUS, mFirstCurveEndPoint.y);

        mSecondCurveControlPoint1.set(mSecondCurveStartPoint.x + (CURVE_CIRCLE_RADIUS * 2) - CURVE_CIRCLE_RADIUS, mSecondCurveStartPoint.y);
        mSecondCurveControlPoint2.set(mSecondCurveEndPoint.x - (CURVE_CIRCLE_RADIUS + (CURVE_CIRCLE_RADIUS / 4)), mSecondCurveEndPoint.y);
    }

Перейдём к более интересному. Для начала обнулим (reset) path для удаления линий и кривых из неё, затем переместим её в начальную точку P1, используя метод moveTo(x,y), и нарисуем первую линию между P1 и P2, используя lineTo(x,y), это добавит линию с последней точки до заданной точки. Так как у нас пока нет последней точки, укажем moveTo(0,0).

Для рисования кубических кривых мы должны использовать метод cubicTo(x1,y1,x2,y2,x3,y3), где первая и вторая точки (x1,y1) (x2,y2) контролируют наклон. Для проверки корректности точек можно использовать эту утилиту. Так же отрисуем вторую кривую и так далее всю панель.

 @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        
        //расчет точки 
        
        mPath.reset();
        mPath.moveTo(0, 0);
        mPath.lineTo(mFirstCurveStartPoint.x, mFirstCurveStartPoint.y);

        mPath.cubicTo(mFirstCurveControlPoint1.x, mFirstCurveControlPoint1.y,
                mFirstCurveControlPoint2.x, mFirstCurveControlPoint2.y,
                mFirstCurveEndPoint.x, mFirstCurveEndPoint.y);

        mPath.cubicTo(mSecondCurveControlPoint1.x, mSecondCurveControlPoint1.y,
                mSecondCurveControlPoint2.x, mSecondCurveControlPoint2.y,
                mSecondCurveEndPoint.x, mSecondCurveEndPoint.y);

        mPath.lineTo(mNavigationBarWidth, 0);
        mPath.lineTo(mNavigationBarWidth, mNavigationBarHeight);
        mPath.lineTo(0, mNavigationBarHeight);
        mPath.close();
    }

Наконец, мы можем рисовать с помощью объекта canvas (канва). Канва содержит несколько методов рисования, и нам подходит canvas.drawPath(Path path, Paint paint).

  @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawPath(mPath, mPaint);
    }

Хорошей разработки!

Код примеров вы сможете найти в моём Github и на Java и на Kotlin. Только выберите правильную ветку.

По материалам: «How I drew custom shapes in bottom bar»

Также по разработке под android рекомендую почитать «Разработка приложения-галереи под Android на Kotlin»

Кастомная нижняя панель в android

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Аутентификация в приложении Flutter

Аутентификация в приложении Flutter

Сегодня мы сделаем небольшое приложение, в котором покажем, как сделать авторизацию, выход из сеанса и удаление профиля в Firebase. И мы будем делать это в Android Studio. Кликнув по ссылке, вы узнаете как установить Flutter для Android Studio. Если вы используете другую IDE, не волнуйтесь, код не будет отличаться.

Если вы только начинаете разрабатывать на Flutter, обратите внимание на статью Создание первого приложения Flutter и продолжение.

Наше приложение будет выглядеть как на скриншоте ниже. Оно сможет авторизовать пользователя в Firebase через Google, завершать сеанс, удалять пользователя в том случае, если он когда-либо авторизовывался в приложении. Также мы добавим круглый индикатор прогресса внизу, чтобы показать, что приложение занято.

Аутентификация в приложении Flutter

Подготовка

Вначале необходимо настроить Google Firebase.

Добавим Firebase в проект

Аутентификация в приложении Flutter
скопируйте файл .json в папку Android/app 

Перейдите в консоль Firebase, создайте новый проект. Затем скачайте файл google-services.json и скопируйте его в папку Android/app/ (как на скриншоте). Не забудьте добавить SHA-1, он понадобится для аутентификации. Кликните здесь, если не знаете, где взять SHA-1.По дальнейшей настройке Flutter документация здесь.

Добавим метод входа (Sign-in)

Аутентификация в приложении Flutter
включим Google Sign-in

Затем в Firebase Console перейдите в Authentication (слева на панели). Здесь есть список Провайдеры авторизации. Для нашего проекта достаточно будет только Google.  

Добавляем зависимости

Откройте файл pubspec.yaml в папке проекта. Добавьте следующие строки ниже dependencies:

      // ./pubspec.yaml
      dependencies:
      //...
      #AUTHENTICATIN
      firebase_auth: ^0.6.2+1
      google_sign_in: ^3.2.2

Нажмите кнопку ‘Packages get’ сверху. Супер, теперь можно перейти к разработке. 

Структура проекта

Аутентификация в приложении Flutter
структура проекта

Я создал папку /scr в /lib. В /scr создал папку /auth. Добавил файл authHelper.dart в /auth, а home.dart и startApp.dart в /src. В принципе, вы можете создать любую структуру, однако, не забудьте поменять пути до файлов в этом случае.

Main.dart

Рассмотрим этот кусок кода. Это точка входа в приложении Flutter. Здесь мы просто запускаем StartApp().

    // ./lib/main.dart
    import 'package:flutter/material.dart';
    import 'src/startApp.dart';
    void main() => runApp(StartApp());

StartApp.dart

В startApp.dart мы пропишем тему приложения. Импортируем сюда home.dart, создадим StatelessWidget, определим цвета темы и основной экран методом Home().

    // ./lib/scr/startApp.dart
    import 'package:flutter/material.dart';
    import 'home.dart';
    class StartApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Auth Tutorial',
          theme: new ThemeData(
            // theme of our application.
            primaryColor: Colors.lightBlue,
            primaryColorDark: Colors.blue,
            accentColor: Colors.orange,
          ),
          home: new Home(),      
        );
      }
    }

Home.dart

Здесь мы импортируем flutter/material.dart и наш вспомогательный модуль authHelper.dart.Затем создаём StatefulWidget. 

    // ./lib/scr/home.dart
    import 'package:flutter/material.dart';
    import 'auth/authHelper.dart';
    class Home extends StatefulWidget {
      @override
      _Home createState() => new _Home();
    }

_Home() будет таким: создаём панель app bar с надписью ‘Flutter Authentication Tutorial’. Ниже у нас будет текстовое поле и три кнопки, ‘Login’, ‘Logout’ и ‘Delete’. Можете разместить их как угодно. Я даже присвоил разные цвета. Также мы добавим круглый индикатор прогресса.

    // ./lib/scr/home.dart
    //...
    class _Home extends State<StatefulWidget> {
      @override
      Widget build(BuildContext context) {    
        return new Scaffold(
          appBar: new AppBar(
            title: new Text('Flutter Authentication Tutorial'),
          ),
          body: new Center(
              child: new Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Padding(
                padding: EdgeInsets.symmetric(vertical: 9.0, horizontal: 18.0),
                child: Text('User: $mUserName'),
              ),
              Padding(
                padding: EdgeInsets.symmetric(vertical: 9.0, horizontal: 18.0),
                child: RaisedButton(
                    color: Colors.green,
                    textColor: Colors.white,
                    splashColor: Colors.greenAccent,
                    child: const Text('LOGIN'),
                    onPressed: isLoggingIn ? null : clickLogin),
              ),
              Padding(
                padding: EdgeInsets.symmetric(vertical: 9.0, horizontal: 18.0),
                child: RaisedButton(
                    color: Colors.blue,
                    textColor: Colors.white,
                    splashColor: Colors.blueAccent,
                    child: const Text('LOGOUT'),
                    onPressed: isLoggingIn ? null : clickLogout),
              ),
              Padding(
                padding: EdgeInsets.symmetric(vertical: 9.0, horizontal: 18.0),
                child: RaisedButton(
                  color: Colors.red,
                  textColor: Colors.white,
                  splashColor: Colors.redAccent,
                  child: const Text('DELETE'),
                  onPressed: mUser == null || isLoggingIn ? null : clickDelete,
                ),
              ),
              new Opacity(
                opacity: cpbOpacity,
                child: new Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    new CircularProgressIndicator(),
                    new SizedBox(width: 20.0),
                    new Text('Please wait...')
                  ],
                ),
              ),
            ],
          )),
        );
      }
    }

Добавим несколько переменных и функций. Для начала, mUser и mUserName. А также булеву isLoggingIn. При каждом успешном входе она будет true. И если она true, кнопки будут неактивны (если onPressed равен null, кнопка неактивна). При клике на кнопку Login будет вызван loginWithGoogle из модуля authHelper. Здесь же мы проверяем ошибки, и если всё хорошо, установим isLoggingIn и выведем имя пользователя (username), которое получим после авторизации.

    // ./lib/scr/home.dart
    class _Home extends State<StatefulWidget> {
      var mUser;
      var mUserName;
      var isLoggingIn = false;
      Future clickLogin() async {
        //установим isLoggingIn в true при клике на Login
        setState(() {
          isLoggingIn = true;
        });
        mUser = await loginWithGoogle().catchError((e) => setState(() {
              isLoggingIn = false;
            }));
        assert(mUser != null);
        setState(() {
          isLoggingIn = false;
          mUserName = mUser.displayName;
        });
      }
      @override
      Widget build(BuildContext context) {
      //...
      }
    }

Создадим методы clickLogout и clickDelete. В clickLogout мы вызываем logoutWithGoogle из authHelper. При успешном выполнении выводим в текстовом поле ‘No user is logged in’. При клике clickDelete, пользователь будет удалён из Firebase.

    // ./lib/scr/home.dart 
    class _Home extends State<StatefulWidget> { 
      //...
      void clickLogout() {
        setState(() {
          isLoggingIn = true;
        });
        logoutWithGoogle().whenComplete(() => setState(() {
              isLoggingIn = false;
              mUserName = 'No user is logged in!';
            }));
      }
      void clickDelete() {
        setState(() {
          isLoggingIn = true;
        });
        deleteUserWithGoogle(mUser).whenComplete(() => setState(() {
              isLoggingIn = false;
              mUser = null;
              mUserName = 'No user is logged in!';
            }));
      }
      @override
      Widget build(BuildContext context) {
        // ...
      }
    }

Допишем home.dart, добавьте эти строки в начале build. Здесь устанавливаем значение непрозрачности, в том случае, когда пользователь аутентифицирован.

    // ./lib/scr/home.dart 
    class _Home extends State<StatefulWidget> { 
      //...
      @override
      Widget build(BuildContext context) {
        var cpbOpacity;
        isLoggingIn ? cpbOpacity = 1.0 : cpbOpacity = 0.0;
        return new Scaffold(
          //...
        );
      }
    }

AuthHelper.dart

Почти закончили. Добавим немного кода в файл authHelper.dart. firebase_auth и google_sign_in необходимы для процесса аутентификации. При вызове loginWithGoogle сперва пытаемся получить пользователя. При неудаче пробуем дальнейшую авторизацию. Если и тут неудача, вызовем функцию signIn. Она откроет меню Google sign-in и если его отменят, получим ошибку. Ждём аутентификацию и пробуем получить пользователя Firebase. Если всё получилось, у нас будет пользователь, при этом он неанонимен.

    // ./lib/scr/auth/authHelper.dart
    import 'dart:async';
    import 'package:firebase_auth/firebase_auth.dart';
    import 'package:google_sign_in/google_sign_in.dart';
    final FirebaseAuth _auth = FirebaseAuth.instance;
    final GoogleSignIn _googleSignIn = new GoogleSignIn();
    Future<FirebaseUser> loginWithGoogle() async {
      GoogleSignInAccount currentUser = _googleSignIn.currentUser;
      if (currentUser == null) {
        currentUser = await _googleSignIn.signInSilently();
      }
      if (currentUser == null) {
        currentUser = await _googleSignIn.signIn();
        if (currentUser == null) {
          throw('Login Canceled');
        }
      }
      final GoogleSignInAuthentication auth = await currentUser.authentication;
      final FirebaseUser user = await _auth.signInWithGoogle(
        idToken: auth.idToken,
        accessToken: auth.accessToken,
      );
      assert(user != null);
      assert(!user.isAnonymous);
      return user;
    }
    //...

Допишем оставшиеся методы logoutWithGoogle и deleteUserWithGoogle. При выходе из сеанса, вызовем signOut из Firebase и signOut из googleSignIn. При удалении пользователя, сперва завершим его сеанс и затем полностью удалим.

    // ./lib/scr/auth/authHelper.dart
    //...
    Future<Null> logoutWithGoogle() async {
      await _auth.signOut();
      await _googleSignIn.signOut();
    }
    Future<Null> deleteUserWithGoogle(FirebaseUser user) async {
      await logoutWithGoogle();
      await user.delete();
    }

Вот и всё. Если у вас есть вопросы/уточнения, задавайте их в комментариях.

Вы пришли с Xamarin? Тогда почитайте Flutter или Xamarin: сравнение инструментов кросс-платформенной мобильной разработки.

Перевод «Flutter Authentication Tutorial»

Подписывайтесь на новости Flutter! https://t.me/flutterdaily

Аутентификация в приложении Flutter

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Создание первого приложения Flutter, часть 2

Создание первого приложения Flutter

Первая часть.

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

Если вы пропустили первый урок, лучше начать с него.

Начнём с простого мобильного приложения, представленного бесконечным списком названий для компании-стартапа — это результат первого урока. В конце этого урока пользователь приложения сможет выбирать понравившиеся наименования и снимать выбор. По клику на иконку в верхнем правом углу будет открываться новая страница со списком наименований в избранном .

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

Создание первого приложения Flutter, часть 2

1. Добавим иконки в список

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

Добавьте набор (Set) _saved в RandomWordsState. Этот Set будет хранить список выбранных пользователем пар слов. Set был выбран из-за того, что не допускает дублей.

class RandomWordsState extends State<RandomWords> {
  final List<WordPair> _suggestions = <WordPair>[];
  final Set<WordPair> _saved = new Set<WordPair>();   // Добавьте эту строку.
  final TextStyle _biggerFont = const TextStyle(fontSize: 18.0);
  ...
}

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

Widget _buildRow(WordPair pair) {
  final bool alreadySaved = _saved.contains(pair);  // Добавьте эту строку.
  ...
}

В _buildRow() добавим иконку-сердечко к объектам ListTile для включения в избранное. Пока это статичные картинки, далее мы добавим в них интерактив:

Widget _buildRow(WordPair pair) {
  final bool alreadySaved = _saved.contains(pair);
  return new ListTile(
    title: new Text(
      pair.asPascalCase,
      style: _biggerFont,
    ),
    trailing: new Icon(   // Добавьте строки отсюда... 
      alreadySaved ? Icons.favorite : Icons.favorite_border,
      color: alreadySaved ? Colors.red : null,
    ),                    // ... до сюда.
  );
}

Перезапустите приложение. Вы должны увидеть незаполненные сердечки в каждой строке.

Проблемы?

Здесь вы можете скачать рабочий код, если вдруг что-то пошло не так:

2. Добавляем интерактивность

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

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

Добавьте onTap, как указано ниже:

Widget _buildRow(WordPair pair) {
  final alreadySaved = _saved.contains(pair);
  return new ListTile(
    title: new Text(
      pair.asPascalCase,
      style: _biggerFont,
    ),
    trailing: new Icon(
      alreadySaved ? Icons.favorite : Icons.favorite_border,
      color: alreadySaved ? Colors.red : null,
    ),
    onTap: () {      // Добавим 9 строк отсюда...
      setState(() {
        if (alreadySaved) {
          _saved.remove(pair);
        } else { 
          _saved.add(pair); 
        } 
      });
    },               // ... до сюда.
  );
}

Замечание: Во Flutter при вызове setState() срабатывает вызов к методу build() для объекта Состояния, в результате перерисовывая UI.

Перезапустите приложение. При клике на строку списка она должна попадать в избранное или удаляться из него. Также в месте клика появляется анимация заполнения.

Проблемы?

Здесь вы можете скачать рабочий код, если вдруг что-то пошло не так:

3. Переход на новый экран

Сейчас мы добавим новую страницу для вывода избранного. Вы узнаете как переходить между основным (home route) и новым роутом.

Стеком, содержащим все роуты приложения, управляет Navigator. Добавление роута в стек Navigator-а, делает переход на этот роут. Удаление роута из стека Navigator-а, возвращает на предыдущий роут.

Добавим иконку списка к AppBar в методе build класса RandomWordsState. По клику на эту иконку, в Navigator добавится новый роут, содержащий элементы избранного.

Добавьте иконку и соответствующее ей действие в метод build:

class RandomWordsState extends State<RandomWords> {
  ...
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text('Startup Name Generator'),
        actions: <Widget>[      // Добавьте 3 строки отсюда...
          new IconButton(icon: const Icon(Icons.list), onPressed: _pushSaved),
        ],                      // ... до сюда.
      ),
      body: _buildSuggestions(),
    );
  }
  ...
}

Замечание: Некоторые свойства виджетов принимают одиночный виджет (child), а другие, такие как действия, принимают массив виджетов (children), это указывается квадратными скобками ([]).

Добавим функцию _pushSaved() в класс RandomWordsState.

  void _pushSaved() {
  }

Перезапустим приложение. Иконка списка появится в верхней панели. Нажатие на неё ни к чему пока не приводит, потому что функция _pushSaved пуста.

Теперь создадим роут и положим в стек Navigator-а. Это действие отобразит новый роут на экране. Содержимое новой страницы построено в MaterialPageRoute builder, в анонимной функции.

Вызовем Navigator.push, как показано ниже. IDE покажет ошибку в коде, но мы это почти сразу поправим.

void _pushSaved() {
  Navigator.of(context).push(
  );
}

Добавим MaterialPageRoute и его построитель (builder). Код ниже создаёт список рядов ListTile. Метод divideTiles() добавляет горизонтальный разделитель между элементами. Переменная divided хранить финальный вид рядов, сконвертированный в список функцией toList().

Добавьте следующий код:

void _pushSaved() {
  Navigator.of(context).push(
    new MaterialPageRoute<void>(   // Добавьте 20 строк отсюда...
      builder: (BuildContext context) {
        final Iterable<ListTile> tiles = _saved.map(
          (WordPair pair) {
            return new ListTile(
              title: new Text(
                pair.asPascalCase,
                style: _biggerFont,
              ),
            );
          },
        );
        final List<Widget> divided = ListTile
          .divideTiles(
            context: context,
            tiles: tiles,
          )
          .toList();
      },
    ),                           // ... до сюда.
  );
}

В коде ниже свойство builder возвращает Scaffold, содержащий верхнюю панель нового роута с названием «Saved Suggestions.» Тело роута представлено ListView с рядами ListTiles; между рядами есть разделители:

void _pushSaved() {
  Navigator.of(context).push(
    new MaterialPageRoute<void>(
      builder: (BuildContext context) {
        final Iterable<ListTile> tiles = _saved.map(
          (WordPair pair) {
            return new ListTile(
              title: new Text(
                pair.asPascalCase,
                style: _biggerFont,
              ),
            );
          },
        );
        final List<Widget> divided = ListTile
          .divideTiles(
            context: context,
            tiles: tiles,
          )
              .toList();

        return new Scaffold(         // Добавьте 6 строк отсюда...
          appBar: new AppBar(
            title: const Text('Saved Suggestions'),
          ),
          body: new ListView(children: divided),
        );                           // ... до сюда.
      },
    ),
  );
}

Перезапустите приложение. Пометьте избранным какое-нибудь словосочетание в списке и затем нажмите иконку списка в верхней панели. Откроется новый экран с избранным. Navigator сам добавил кнопку «Назад» в панель. Вам даже не потребуется реализация метода Navigator.pop — просто нажав на неё, вы попадёте на основной экран.

Проблемы?

Здесь вы можете скачать рабочий код, если вдруг что-то пошло не так:

4. Смена UI через тему

Сейчас мы изменим тему приложения. Параметр theme отвечает за внешний вид вашего приложения. Вы можете использовать как тему по-умолчанию (зависит от устройства), так и изменить её по вашему желанию.

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

Смените цвет в классе MyApp:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Startup Name Generator',
      theme: new ThemeData(          // Добавьте 3 строки отсюда... 
        primaryColor: Colors.white,
      ),                             // ... до сюда.
      home: new RandomWords(),
    );
  }
}

Перезапустите приложение. Общий фон теперь белый, даже верхняя панель.

Как упражнение для практики, воспользуйтесь ThemeData для изменения других элементов UI. Класс Colors в библиотеке Material предоставляет множество цветов, а «горячий» перезапуск делает эксперименты с UI очень лёгкими.

Проблемы?

Здесь вы можете скачать рабочий код, если вдруг что-то пошло не так:

5. Готово!

Вы создали интерактивное приложение Flutter, работающее под iOS и Android и научились:

  • Писать код на Dart.
  • Использовать «горячий» перезапуск приложения для ускорения разработки.
  • Создавать виджеты с изменяемым состоянием (stateful), добавлять интерактивность в приложение.
  • Создавать роуты (route) и логику для перемещений между основным и новым роутами.
  • Изменять интерфейс приложения с помощью тем оформления.

Почитайте также Stateful-виджеты должны исчезнуть: Stateful Builder.

Перевод «Write Your First Flutter App, part 2»

Подписывайтесь на новости Flutter! https://t.me/flutterdaily

Создание первого приложения Flutter, часть 2

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Flutter или Xamarin: сравнение инструментов кросс-платформенной мобильной разработки

flutter против xamarin

За последнее десятилетие мы наблюдали огромный рост мобильной индустрии, особенно в отношении разработки приложений. По данным Statista Reports, в мире тогда насчитывалось более 2 миллиардов пользователей смартфонов, а к концу 2022 года их число увеличится до более чем 5 миллиардов. Из этих смартфонов почти 100 процентов работают на трех популярных платформах: Android от Google, iOS от Apple и Windows Mobile, разработанной Microsoft. Долгое время разработчики мобильных приложений зависели от инструментов, привязанных к одной платформе. Например, Kotlin и Java в основном используются для разработки нативных мобильных приложений под Android, в то время как разработчики iOS используют Objective-C, а с недавних пор и Swift.

Недостатки нативной разработки  мобильных приложений

Традиционная разработка мобильных приложений была медленной и дорогой, поскольку компаниям приходилось разрабатывать отдельные приложения для каждой платформы, нанимать разные команды разработчиков для своего инструмента разработки. Сегодня у нас появились кросс-платформенные средства мобильной разработки, которые помогают компаниям сократить время на разработку, затраты на обслуживание, привлечь больше пользователей. Упрощённо: кросс-платформенная мобильная разработка это создание мобильных приложений, которые могут работать на нескольких платформах. Сегодня разработчикам доступно несколько кросс-платформенных средств, в том числе Intel XDK, Xamarin, Cordova и Flutter. В этой статье мы сфокусируемся на двух — Xamarin и Flutter — путем сравнения характеристик, сильных сторон и недостатков каждой, чтобы помочь разработчикам выбрать лучший инструмент.

Если вы только начинаете разрабатывать на Flutter, обратите внимание на статью Создание первого приложения Flutter и продолжение.

Обзор фреймворков Xamarin и Flutter 

Xamarin от Microsoft, возможно, является одной из ведущих технологий кросс-платформенной разработки с открытым исходным кодом. Он использует язык C# для разработки мобильных приложений под Android, iOS и Windows Mobile. Xamarin позволяет разработчикам получать доступ к нативным API под Android и iOS, предоставляет общую кодовую базу на C#, а также тестирование приложений на разных устройствах с Xamarin Testing Cloud. Xamarin был создан в 2011 году разработчиками Mono, которые использовали CLI (Common Language Infrastructure) и Common Language Specification, также известную как Microsoft .NET. Microsoft приобрела Xamarin в 2016 году, а затем сделала Xamarin SDK платформой с открытым исходным кодом, которая стала неотъемлемой частью Xamarin Visual Studio IDE. Чтобы использовать весь потенциал Xamarin, разработчикам понадобятся знания iOS и Android, кроме, собственно, языка C#.

Flutter также является бесплатным кросс-платформенным инструментом с открытым исходным кодом, разработанным Google, и позволяет разработчикам создавать высокопроизводительные мобильные приложения для Android и iOS. Фреймворк использует язык программирования Dart от Google и легкий движок C++. Подобно Xamarin, он позволяет использовать единую кодовую базу для разных платформ. Фреймворк предлагает API и SDK для 2D-рендеринга, моделирования, жестов и рисования, а также позволяет использовать существующий код Swift, Objective C и Java. Он поставляется с виджетами Material Design, также продуктом Google.

Сравнение Flutter и Xamarin

Несмотря на то, что Flutter является относительно новым в мобильной разработке (бета-версия была выпущена в январе 2018 года), появление Flutter вызвало бурные дискуссии в сообществе мобильных разработчиков.

В настоящее время Xamarin более популярен среди разработчиков: 7,2 процента опрошенных в Stack Overflow 2018 заявили, что они используют Xamarin, а Microsoft утверждает, что у них 1,4 миллиона инженеров Xamarin, в то время как Flutter не попал в список вообще. Сообщество Flutter еще недостаточно велико. Тем не менее, некоторые инженеры признают Flutter в качестве подающей надежды альтернативы Xamarin.

Ниже мы рассмотрим характеристики и основные функции обоих фреймворков.

Flutter или Xamarin: сравнение инструментов кросс-платформенной мобильной разработки

Переносимость

Flutter нацелен на разработку под Android и iOS, в то время как  Xamarin поддерживает разработку под Android, iOS, Windows (Windows 10 [UWP], устаревшие Windows-приложения [WPF]), а также MacOS. Бóльшая экосистема дает Xamarin преимущество перед Flutter. Но тот факт, что приложения Flutter не портируются на мобильную платформу Windows, не является недостатком, как утверждает недавний опрос Statista, так как на текущий момент около 98,5% смартфонов работают на Android или iOS. Но Xamarin в этом плане может стать лучшим выбором, если вы всё же хотите разрабатывать приложения и под Windows Mobile.

Flutter несовместим со старыми устройствами с 32-разрядными ОС. Поэтому, если вы планируете разрабатывать под старые телефоны, такие как, например, iPhone 5, Xamarin будет лучшим выбором.

Языки программирования

Xamarin использует C#, который популярен и широко используется разработчиками. Если у вас уже есть навыки в C# и .NET, вы можете сразу использовать Xamarin. И вы можете повторно использовать до 96 процентов своего кода на C# в Xamarin, если вы работаете с Xamarin.Forms.

Flutter использует язык Dart, который является относительно новым и непопулярным. Однако, если у вас есть опыт работы на языках ООП, таких как Java, JavaScript и C++, изучение Dart не будет проблемой, так как использует тот же подход и парадигмы.

Поддержка сообщества

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

Хотя Flutter и поддерживается Google, он относительно новый и не имеет пока широкой поддержки сообщества. Платформа также довольно молода, официального релиза пока не было, а это значит, что разработчикам еще предстоит обнаружить основные недостатки и сильные стороны платформы.

Если вы планируете долгосрочный, сложный проект, выбирайте Xamarin, чья стабильность и проблемы уже известны. Однако, сообщество Flutter очень быстро растёт, и в течение года оно уже может стать достаточно зрелым, чтобы суметь решить большинство возникающих проблем.

Доступ к нативному уровню ОС

Код Dart во Flutter компилируется в нативный код, используя компиляцию AoT (Ahead of Time), но по-прежнему требует Dart VM (виртуальная машина). Причина компиляции AoT заключается в том, что платформа iOS не поддерживает JIT или динамическую компиляцию. Flutter также позволяет создавать пользовательские плагины, которые поддерживают код, специфичный для платформы.

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

Flutter или Xamarin: сравнение инструментов кросс-платформенной мобильной разработки

В Xamarin код C# скомпилирован в машинный код, а затем упакован в .app. Генератор кода mono использует компиляцию JIT для приложений Xamarin.Android и компиляцию AoT для приложений iOS для компиляции промежуточного машинного кода (также известного как управляемый код) в нативный код платформы.

Xamarin использует .NET API и библиотеки, специфичные для платформы, посредством привязок (bindings) для доступа к нативным функциям.

Flutter или Xamarin: сравнение инструментов кросс-платформенной мобильной разработки

Дизайн UI

Использование в Xamarin нативных компонентов пользовательского интерфейса (UI) в целом неплохой механизм, однако это сопряжено с определенными затратами — платформы регулярно обновляются, и для фреймворка может потребоваться много времени на адаптацию к новой версии. Поэтому в Xamarin лучше держать общий код только для логики приложения, но не UI. Кроме того, мы рекомендуем использовать нативные модули для обработки тяжелой графики, такой как игры и анимации.

Flutter использует встроенные виджеты и не использует нативные  компоненты пользовательского интерфейса. Виджеты, как ожидается, будут оптимально настроены к платформе, для которой вы создаете пользовательский интерфейс. В настоящее время Flutter предлагает множество макетов, виджетов, платформ для создания графики и поддержки 2D-API, жестов, эффектов, анимации и других функций. Хотя функции интерфейса Flutter все еще находятся в разработке, они могут превратиться в мощную среду пользовательского интерфейса. Кроме того, Flutter поставляется с компонентами Material Design. В конце концов, это Google.

Средства разработки

Функция горячей перезагрузки в Flutter помогает разработчикам создавать пользовательские интерфейсы, экспериментировать и добавлять различные функции, а также быстро обнаруживать и исправлять ошибки без потери состояния на эмуляторах. Разработчики также могут обращаться к нативным функциям, таким как SDK сторонних разработчиков и библиотекам, повторно использовать существующий нативный код (Swift, Objective C, Java и Kotlin).

Одним из преимуществ Xamarin является то, что он позволяет разработчикам тестировать приложения на разных устройствах в Microsoft Xamarin Cloud. Однако вы должны платить абонентскую плату за доступ к этой функции. Xamarin также предоставляет функцию живой перезагрузки, такую же почти, как во Flutter, которая помогает разработчикам изменять XAML и видеть результат «вживую» без компиляции или установки приложения.

Одной из проблем Xamarin является интеграция с сторонними библиотеками, которая, по-видимому, лучше реализована во Flutter.

Опыт разработки

Xamarin позволяет переиспользовать около 96 процентов кода C#, но если вам понадобится полностью нативная производительность, вам придется использовать платформозависимый код. Таким образом, Xamarin выигрывает тогда, когда разработчики имеют опыт работы на C#, а также могут работать с такими платформами, как Java, Kotlin, Swift и Objective-C.

Для разработки приложений в Flutter вам необходимо освоить Dart, что может отнять у вас немного времени, однако, если вы знакомы с Java и C ++, это не будет проблемой. Если вы новичок в разработке мобильных приложений и не имеете опыта разработки приложений для Android или iOS, вам нужно вначале хорошо освоить Dart.

Размер приложения и APK

Согласно испытаниям Корхана Бикарна, инженера Capital One, простое приложение Flutter имеет размер бинарника 40,2 МБ, а такое же на Xamarin — 25,1 МБ. Размер APK для такого приложения Flutter занимает почти 8 Мбайт, а на Xamarin — около 7 МБ. Здесь вы можете увидеть более подробные результаты.

Использование памяти, процессора и графического процессора

Согласно тому же сравнению, инициализация адресного пространства приложения и динамическое связывание заняло 1,05 секунды в Flutter. Приложение запускается за ~220 мс и работает на 58 FPS. В Xamarin инициализация адресного пространства приложения и динамическое связывание отняли 3,2 секунды. Приложение запускается примерно за 345 мс на 53 FPS. Вы также можете узнать больше о производительности Xamarin в специальной статье.

Выводы

Хотя разработчики сейчас и выбирают чаще Flutter, это не означает, что Xamarin устарел. Имеет смысл выбрать Flutter, если вы новичок в кросс-платформенной мобильной разработке, так как эта платформа, вероятно, станет очень популярной в ближайшем будущем.

Тем не менее, платформа Xamarin в настоящее время более зрелая во многих отношениях, включая сообщество, набор инструментов и стабильность. Для сложных и долгосрочных проектов мы рекомендуем остановиться на Xamarin, особенно если у вас есть команда разработчиков на C# и .NET, и вы работаете с экосистемой Microsoft.

Перевод: «Flutter vs Xamarin Cross-Platform Mobile Development Compared»

Подписывайтесь на новости Flutter! https://t.me/flutterdaily

Flutter или Xamarin: сравнение инструментов кросс-платформенной мобильной разработки

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Flutter и перспектива

Создание первого приложения Flutter

Немного поиграем с 3D и виджетом Transform

В этой статье мы создадим небольшое приложение, демонстрирующее работу виджета Transform по отрисовке 3D перспективы. Мы увидим, как легко это сделать с Flutter, при том, что это сделает не каждый на нативных виджетах.

Ниже скрин приложения в работе (маленький кружок показывает позицию пальца пользователя на экране):

Flutter и перспектива

Приступим

Создайте проект командой flutter create или же средствами вашей IDE. Добавьте два виджета в наше приложение:  Transform и GestureDetector.

Первый:

// v1: move default app to separate function with fixed name
// Add transform widget, rotate and perspective
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Perspective',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key); // changed

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  Offset _offset = Offset(0.4, 0.7); // new

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Transform(  // Transform widget
      transform: Matrix4.identity()
        ..setEntry(3, 2, 0.001) // perspective
        ..rotateX(_offset.dy)
        ..rotateY(_offset.dx),
      alignment: FractionalOffset.center,
      child: _defaultApp(context),
    );
  }

  _defaultApp(BuildContext context) {  // new
    return Scaffold(
      appBar: AppBar(
        title: Text('The Matrix 3D'), // changed
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

}

Вот что должно получиться при запуске:

Flutter и перспектива

Здесь мы немного почистили типовой код, убрав ненужные комментарии и уже ненужное в Dart 2 ключевое слово new. Мы переместили часть представления (метод build из _MyHomePageState) в отдельный метод _defaultApp (строки 49–74). И для простоты  присвоили название прямо в AppBar (строка 52), вместо передачи его параметром в MyHomePage.

Виджет Transform

Добавленный виджет Transform описан в строках 39–46. Посмотрим на него внимательнее. Transform принимает 3D матрицу перехода, т.е. Matrix4. Почему 3D матрица? Разве Flutter не для двумерной графики?

Практически все современные смартфоны, за исключением самых слабых, имеют невероятно быстрый GPU, оптимизированный для 3D графики. Следовательно, практически всё, что вы видите на смартфоне, это обработка в 3D, даже изначально двумерные объекты. Немного странно, да?

Изменяя параметры матрицы, мы манипулируем отображением представления (даже в 3D!). Типичные трансформации включают перемещение, поворот, изменение масштаба и перспективы. Создаём экземпляр матрицы методом identity (строка 40), затем трансформируем его. Трансформации не коммутативны, поэтому нам необходимо определять их в правильном порядке. Законченная матрица будет отправлена GPU для трансформации затронутых ею объектов.

Трансформация это довольно непростая тема, но если вы хотите узнать больше про неё, погуглите материалы по 3D графике, а также  по матрицам перехода и однородным координатам.

Перспектива

Первая трансформация (строка 41) затрагивает изменение перспективы. Перспектива делает удалённые объекты меньше. Установим настройки матрицы так: ряд 3, колонка 2, увеличение 0.001.

Что же за число 0.001? Путём увеличения и уменьшения этого значения, поиграйте с перспективой — это будет похоже на  увеличение/уменьшение картинки в объективе камеры. Чем больше это число, тем более выражена перспектива — как будто вы стоите ближе к объекту.

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

Повороты

Затем в строках 42 и 43 мы задаём два поворота на основе значения переменной _offset (в стр. 29; а ниже мы используем эту же переменную для отслеживания положения пальца пользователя). И что удивительно, поворот по оси X зависит от смещения Y, а поворот по оси Y — от смещения X. Почему так?

Flutter и перспектива

Рассмотрим это изображение, на котором зелеными стрелками указаны оси X и Y. Эти оси начинаются в верхнем левом углу дисплея (именно поэтому ось Y указывает вниз), но наша программа устанавливает начало координат (в строке 44) из центра дисплея.

Поворот задаётся по отношению к оси, метод rotateX определяет поворот вокрут оси X, которая отклоняется по направлению оси Y (вверх-вниз). Точно так же, rotateY отклоняется в направлении X (лево-право) (вокруг оси Y). Поэтому rotateX контролируется с _offset.dy и rotateY с _offset.dx.

Также здесь есть ось Z, которая идёт перпендикулярно экрану, таким образом значение Z положительно при удалении от зрителя. И метод rotateZ поворачивает объект в плоскости дисплея.

Взаимодействие

Ну и наконец мы должны добавить виджет GestureDetector:

// v2: add Gesture detector
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Perspective',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key); // changed

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  Offset _offset = Offset.zero; // changed

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Transform(  // Transform widget
      transform: Matrix4.identity()
        ..setEntry(3, 2, 0.001) // perspective
        ..rotateX(0.01 * _offset.dy) // changed
        ..rotateY(-0.01 * _offset.dx), // changed
      alignment: FractionalOffset.center,
      child: GestureDetector( // new
        onPanUpdate: (details) => setState(() => _offset += details.delta),
        onDoubleTap: () => setState(() => _offset = Offset.zero),
        child: _defaultApp(context),
      )
    );
  }

  _defaultApp(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('The Matrix 3D'), // changed
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

}

В строке 28 _offset инициализируется в значении нуля. Строки 44–48 определяют GestureDetector, отслеживающий два вида жестов: перемещение (напр., движения пальца по экрану) и двойной тап. В строке 45 увеличиваем количество движений (в пикселях) в переменной _offset. В строке 46 обнуляем _offset при двойном тапе пользователя по экрану. И для обоих жестов метод setState() перерисовывает экран.

В строках 41 и 42 смещение (в пикселях) умножается на 0.01 для удобства расчета вращения (оно указывается в радианах, где полное вращение это 2π , т.е. приблизительно 6.28  — таким образом для полного вращения потребуется перемещение на 628 пикселей).

Готово!

Конечно, ни один из этих объектов не является по-настоящему трёхмерным. Все они двумерные (плоские) (даже искусственные тени от AppBar и FAB). Однако, это не мешает нам крутить их в трёхмерном пространстве как угодно.

Ещё один пример

Flutter и перспектива

Итак, сделаем красивый псевдотрёхмерный эффект, наподобие такого:

Flutter и перспектива

Как это работает

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

Как это воплотить в коде? Разобьём эту задачу на две:

  • Разрезаем элемент на две половины
  • Вращаем одну половинку вокруг оси X

Почитав документацию Flutter, я нашёл два виджета, подходящих для этой задачи: ClipRect и Transform.

Реализация

  • Разрезаем элемент на две половины:

Виджет ClipRect имеет параметр clipper для установки параметров изображения, но также есть другой способ использования ClipRect, в комбинации с Align:

class FlipWidget extends StatelessWidget {
  Widget child;

  FlipWidget({Key key, this.child}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        ClipRect(
            child: Align(
          alignment: Alignment.topCenter,
          heightFactor: 0.5,
          child: child,
        )),
        Padding(
          padding: EdgeInsets.only(top: 2.0),
        ),
        ClipRect(
            child: Align(
          alignment: Alignment.bottomCenter,
          heightFactor: 0.5,
          child: child,
        )),
      ],
    );
  }
}

Пробуем:

Flutter и перспектива

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

  • Вращаем одну половинку вокруг оси X:

Виджет Transform имеет параметр transform типа Matrix4 для выбора вида применяемой трансформации, также Matrix4 имеет конструктор rotationX(), похоже, что мы можем его использовать для верхней половинки панели:

@override
Widget build(BuildContext context) {
   return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Transform(
          transform: Matrix4.rotationX(pi / 4),
          alignment: Alignment.bottomCenter,
          child: ClipRect(
              child: Align(
            alignment: Alignment.topCenter,
            heightFactor: 0.5,
            child: child,
          )),
        ),
        ...
      ],
    );
}

Пробуем:

Flutter и перспектива

Правда, это похоже на эффект масштабирования?

Изменим передаваемые значения в Matrix4 должно всё исправить:

...
Transform(
  transform: Matrix4.identity()..setEntry(3, 2, 0.006)..rotateX(pi / 4),
  alignment: Alignment.bottomCenter,
  child: ClipRect(
      child: Align(
    alignment: Alignment.topCenter,
    heightFactor: 0.5,
    child: child,
  )),
),
...

Пробуем:

Flutter и перспектива

Осталось добавить анимацию для нашего виджета. Здесь немного сложнее. Фактически, каждая панель содержит контент с обеих сторон (спереди и сзади), но в один момент времени видна только одна сторона. Думаю, что нам нужно создать анимацию, в которой панели поворачиваются вверх — она будет состоять из двух этапов (последовательно), первый — перевернуть нижнюю половину вверх и показать нижнюю половину следующей панели, в то же время скрыть нижнюю половину текущей панели, а второй этап — перевернуть верхнюю половину в том же направлении и открыть верхнюю половину следующего, тут же спрятав верхнюю половину текущей:

Flutter и перспектива

Полный код можно скачать здесь.

Вот что получим в итоге:

Flutter и перспектива

Написано по материалам статей «Perspective on Flutter» и «Make 3D flip animation in Flutter»

Если вы только начинаете разрабатывать на Flutter, обратите внимание на статью Создание первого приложения Flutter и продолжение.

Подписывайтесь на новости Flutter! https://t.me/flutterdaily

Flutter и перспектива

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Сканер штрихкодов на Flutter

Создание первого приложения Flutter

Мне всегда хотелось попробовать сделать сканер штрихкодов под Android, но никогда не находилось достаточно мотивации для этого… Но не сейчас 🙂

Сканер штрихкодов на Flutter
Общий вид приложения

В поиске нужного пакета для Flutter я нашел barcode_scan

Начнём

Сканер штрихкодов на Flutter
Добавляем зависимости

Добавляем зависимость barcode_scan : “0.0.4”  в файл pubspec.yaml.

Если вы только начинаете разрабатывать на Flutter, обратите внимание на статью Создание первого приложения Flutter и продолжение.

Затем добавьте следующую строку в Android Manifest вашего приложения:

<activity android:name="com.apptreesoftware.barcodescan.BarcodeScannerActivity"/>
Сканер штрихкодов на Flutter
Измененный Android Manifest.xml

При клике на кнопку Capture Image, начнётся сканирование.

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

Сканер штрихкодов на Flutter
Ошибка в случае не предоставления доступа

Если разрешение получено, произойдёт считывание изображения со штрихкодом и на экран покажется сообщение с текстом кода:

Сканер штрихкодов на Flutter
Штрихкод отсканирован

Кусочек кода сканирования :

Сканер штрихкодов на Flutter
Сканирование кода

Вот и всё! Кратко о том же на видео:

Полный код программы можно скачать здесь https://github.com/AseemWangoo/flutter_programs/blob/master/barcode.dart

Как написать плагин для Flutter?

Перевод статьи Barcode Scanner in Flutter

Подписывайтесь на новости Flutter! https://t.me/flutterdaily

Сканер штрихкодов на Flutter

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Разработка приложения-галереи под Android на Kotlin

Разработка приложения-галереи под Android на Kotlin

В этом уроке мы разработаем полноценное приложение — галерею изображений на Kotlin. Также мы рассмотрим следующие темы:

  • Реализация паттерна Singleton на Kotlin.
  • Передача экземпляра класса из одной activity в другую с помощью Parcelable на Kotlin
  • Пример реализации Shared preferences.
  • Пример Recyclerview на Kotlin.
  • Настройка RecyclerView Adapter на Kotlin.
  • NavigationView и настройка DrawerLayout на Kotlin.
  • Загрузка и вывод изображений с внутреннего накопителя Android на Kotlin.
  • Использование библиотеки для загрузки изображений Glide.
  • Работа с разрешениями в режиме выполнения в Android.


Если вы ещё новичок и только планируете освоить Kotlin, рекомендую вначале ознакомиться со следующими материалами:

  1. Идиомы Kotlin (англ.)
  2. Классы, объекты, модификаторы и интерфейсы в Kotlin (англ.)

Таким образом, разработав это приложение, вы неплохо освоите Kotlin.

Итак, приступим.

Создадим новый проект в Android Studio и включим галочку “Add support for Kotlin” при его создании.

Добавим необходимые разрешения в манифест.
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
Создадим Data Class “Albums” и реализуем интерфейс Parcelable.

Вот наш класс модели, хранящий информацию о папках изображений (например, Whatsapp Images, Camera), пути до последних изображений в папке, общем количестве изображений в каждой папке.

И так как мы планируем передать эту информацию с окна запуска приложения в MainActivity с помощью Intent, мы должны реализовать интерфейс Parcelable в классе “Albums”:

data class Albums(var folderNames: String, var imagePath: String, var imgCount: Int, var isVideo: Boolean) : Parcelable {
    constructor(parcel: Parcel) : this(
            parcel.readString(),
            parcel.readString(),
            parcel.readInt(),
            parcel.readByte() != 0.toByte()) {
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(folderNames)
        parcel.writeString(imagePath)
        parcel.writeInt(imgCount)
        parcel.writeByte(if (isVideo) 1 else 0)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<Albums> {
        override fun createFromParcel(parcel: Parcel): Albums {
            return Albums(parcel)
        }

        override fun newArray(size: Int): Array<Albums?> {
            return arrayOfNulls(size)
        }
    }
}
Добавим Splash Activity

В методе onCreate нашей Splash Activity нам сперва необходимо получить доступ к внешнему накопителю. Для этого мы должны запросить у пользователя разрешение на доступ к изображениям на устройстве.

После того, как пользователь даст разрешение read_external_storage, мы сможем загрузить изображения и передать имя изображения, его путь и количество изображений в папках в нашу Main Activity для вывода изображений в Recyclerview.

// SplashActivity.kt

internal var SPLASH_TIME_OUT = 800

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity_splash)

    Handler().postDelayed(
            {
                 // проверка того, что пользователь дал разрешение на доступ к накопителю.
                 // в противном случае запросим такое разрешение.
                if (!checkSelfPermission()) {
                    requestPermission()
                } else {
                   // если разрешение получено, загрузим изображения.
                   // исходный код этого метода будет описан ниже.
                    loadAllImages()
                }
            }, SPLASH_TIME_OUT.toLong())
}
Добавим код, перехватывающий выдачу разрешения.
private fun requestPermission() {
    ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 6036)
}

private fun checkSelfPermission(): Boolean {

    if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
        return false
    } else
        return true
}

Когда пользователь даст разрешение или откажет в нём, выполнится следующий код:

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
    when (requestCode) {
        6036 -> {
            if (grantResults.size > 0) {
                var permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED
                if (permissionGranted) {

    // Теперь мы готовы к чтению внешнего хранилища и имеющихся там изображений.

                    loadAllImages()
                } else {
                    Toast.makeText(this, "Permission Denied! Cannot load images.", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

Если разрешение получено, мы передадим загруженные изображения в MainActivity.kt

Передаём экземпляр класса данных (класса модели) в intent-е как Parcelable.

Эта функция вызовет другой метод getAllShownImagesPath(context), которая вернёт List типа Albums.

Передадим полученный список Albums в MainActivity с помощью Intent.

MainActivity выведет список всех папок с изображениями и видео (типа Camera, Instagram, Whatsapp Images) в RecyclerView.

fun loadAllImages() {
    var imagesList = getAllShownImagesPath(this)
    var intent = Intent(this, MainActivity::class.java)
    intent.putParcelableArrayListExtra("image_url_data", imagesList)
    startActivity(intent)
    finish()
}

В этом куске кода у нас есть экземпляр Data Class и нам необходимо передать эти данные в другую activity, поэтому мы передаём сериализованные данные.

Загрузка изображений из внутреннего хранилища.

Здесь мы рассмотрим чтение всех папок с изображениями и видео с помощью класса MediaStore. И вернём ArrayList.

private fun getAllShownImagesPath(activity: Activity): ArrayList<Albums> {

    val uri: Uri
    val cursor: Cursor
    var cursorBucket: Cursor
    val column_index_data: Int
    val column_index_folder_name: Int
    val listOfAllImages = ArrayList<String>()
    var absolutePathOfImage: String? = null
    var albumsList = ArrayList<Albums>()
    var album: Albums? = null


    val BUCKET_GROUP_BY = "1) GROUP BY 1,(2"
    val BUCKET_ORDER_BY = "MAX(datetaken) DESC"

    uri = android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI

    val projection = arrayOf(MediaStore.Images.ImageColumns.BUCKET_ID,
            MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME,
            MediaStore.Images.ImageColumns.DATE_TAKEN,
            MediaStore.Images.ImageColumns.DATA)

    cursor = activity.contentResolver.query(uri, projection, BUCKET_GROUP_BY, null, BUCKET_ORDER_BY)

    if (cursor != null) {
        column_index_data = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
        column_index_folder_name = cursor
                .getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
        while (cursor.moveToNext()) {
            absolutePathOfImage = cursor.getString(column_index_data)
            Log.d("title_apps", "bucket name:" + cursor.getString(column_index_data))

            val selectionArgs = arrayOf("%" + cursor.getString(column_index_folder_name) + "%")
            val selection = MediaStore.Images.Media.DATA + " like ? "
            val projectionOnlyBucket = arrayOf(MediaStore.MediaColumns.DATA, MediaStore.Images.Media.BUCKET_DISPLAY_NAME)

            cursorBucket = activity.contentResolver.query(uri, projectionOnlyBucket, selection, selectionArgs, null)
            Log.d("title_apps", "bucket size:" + cursorBucket.count)

            if (absolutePathOfImage != "" && absolutePathOfImage != null) {
                listOfAllImages.add(absolutePathOfImage)
                albumsList.add(Albums(cursor.getString(column_index_folder_name), absolutePathOfImage, cursorBucket.count, false))
            }
        }
    }
    return getListOfVideoFolders(albumsList)
}

// Эта функция отвечает за чтение всех видео из всех папок.
private fun getListOfVideoFolders(albumsList: ArrayList<Albums>): ArrayList<Albums> {

    var cursor: Cursor
    var cursorBucket: Cursor
    var uri: Uri
    val BUCKET_GROUP_BY = "1) GROUP BY 1,(2"
    val BUCKET_ORDER_BY = "MAX(datetaken) DESC"
    val column_index_album_name: Int
    val column_index_album_video: Int

    uri = android.provider.MediaStore.Video.Media.EXTERNAL_CONTENT_URI

    val projection1 = arrayOf(MediaStore.Video.VideoColumns.BUCKET_ID,
            MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME,
            MediaStore.Video.VideoColumns.DATE_TAKEN,
            MediaStore.Video.VideoColumns.DATA)

    cursor = this.contentResolver.query(uri, projection1, BUCKET_GROUP_BY, null, BUCKET_ORDER_BY)

    if (cursor != null) {
        column_index_album_name = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
        column_index_album_video = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
        while (cursor.moveToNext()) {
            Log.d("title_apps", "bucket video:" + cursor.getString(column_index_album_name))
            Log.d("title_apps", "bucket video:" + cursor.getString(column_index_album_video))
            val selectionArgs = arrayOf("%" + cursor.getString(column_index_album_name) + "%")

            val selection = MediaStore.Video.Media.DATA + " like ? "
            val projectionOnlyBucket = arrayOf(MediaStore.MediaColumns.DATA, MediaStore.Video.Media.BUCKET_DISPLAY_NAME)

            cursorBucket = this.contentResolver.query(uri, projectionOnlyBucket, selection, selectionArgs, null)
            Log.d("title_apps", "bucket size:" + cursorBucket.count)

            albumsList.add(Albums(cursor.getString(column_index_album_name), cursor.getString(column_index_album_video), cursorBucket.count, true))
        }
    }
    return albumsList
}
Итак, что мы уже сделали
  1. Запрос разрешения на чтение изображений в режиме выполнения.
  2. С помощью MediaStore и Cursor реализовали загрузку путей до изображений из внутренней памяти.
  3. Научились передавать экземпляр сериализованного класса данных (Serialized Data Class) из одной Activity в другую на Kotlin.
  4. Поняли как использовать Data Class в Kotlin (Albums.kt).

Теперь создадим другую activity и назовём её MainActivity.kt. В ней мы сделаем следующее:

  1. Мы получим объект Albums, отправленный из Splash Activity.
  2. Напишем код слушателя нажатия на элемент RecyclerView.
  3. Создадим файл макета (разметки) и адаптер RecyclerView для вывода всех папок.
Разметка для MainActivity

//файл activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <android.support.v7.widget.Toolbar
        android:id="@+id/my_toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"
        android:elevation="4dp"
        app:titleTextColor="@color/colorIcons" />
    
    <android.support.v4.widget.DrawerLayout
        android:id="@+id/drawer_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@+id/my_toolbar"
        android:fitsSystemWindows="true">

        <!-- Ваш контент -->
        <include layout="@layout/include_main_content"></include>

        <android.support.design.widget.NavigationView
            android:id="@+id/navigation"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_gravity="start"
            android:onClick="toast"
            app:menu="@menu/my_navigation_menu"
            app:theme="@style/NavigationDrawerStyle" />
    </android.support.v4.widget.DrawerLayout>
</RelativeLayout>



//include_main_content.xml 

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <android.support.v7.widget.RecyclerView
        android:id="@+id/rvAlbums"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false" />

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab_camera"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|right"
        android:layout_margin="16dp"
        android:src="@drawable/ic_photo_camera_white_24dp"
        app:layout_anchor="@id/rvAlbums"
        app:layout_anchorGravity="bottom|right|end"
        app:layout_behavior="com.title_apps.canalpic.util.ScrollAwareFABBehavior" />

</android.support.design.widget.CoordinatorLayout>



//MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    savedState = savedInstanceState

    if (savedState != null)
        folder_name = savedInstanceState!!.getString("folder_name")

    setSupportActionBar(my_toolbar)
    // Включим кнопку "Вверх" (Up button)
    supportActionBar!!.setDisplayHomeAsUpEnabled(true)
    supportActionBar!!.setHomeAsUpIndicator(resources.getDrawable(R.drawable.ic_menu_white_24dp))

    setupNavigationView()

    var extra = intent.extras;
    if (extra != null) {
        var extraData = extra.get("image_url_data") as ArrayList<Albums>
        select_fragment(extraData)
    }

    drawer_layout_listener()
    supportActionBar!!.setTitle("Folders")
}



// в этом методе мы посылаем имя папки, по которой кликнул пользователь. К примеру, если пользователь
// нажал на папку downloads из общего списка папок нашего приложения, мы передадим это 
// имя папки следующей activity, которая загрузит и выведет на экран все изображения из этой папки.

override fun onItemClick(position: String, isVideo: Boolean) {

    var bundle = Bundle()
    bundle.putString("folder_name", position)
        var intent = Intent(this, AlbumActivity::class.java)
        intent.putExtra("folder_name", position)
        startActivity(intent)
}
Инициализируем Glide и настраиваем RecyclerView для вывода изображений.
private var folder_name: String = ""

public fun select_fragment(imagesList: ArrayList<Albums>) {

    val options = RequestOptions()
            .diskCacheStrategy(DiskCacheStrategy.RESOURCE).override(160, 160).skipMemoryCache(true).error(R.drawable.ic_image_unavailable)
    val glide = Glide.with(this)

    val builder = glide.asBitmap()
    rvAlbums?.layoutManager = GridLayoutManager(this, 2)

    rvAlbums?.setHasFixedSize(true)

    // AlbumFoldersAdapter.kt это класс адаптера RecyclerView.
    rvAlbums?.adapter = AlbumFoldersAdapter(imagesList, this, options, builder, glide, this)


    rvAlbums?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
        }

        override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            when (newState) {
                RecyclerView.SCROLL_STATE_IDLE -> glide.resumeRequests()
                AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL, AbsListView.OnScrollListener.SCROLL_STATE_FLING -> glide.pauseRequests()
            }
        }
    }
    )

    fab_camera?.setOnClickListener(object : View.OnClickListener {
        override fun onClick(p0: View?) {
            launchCamera()
        }
    }
    )
}
Слушатели для NavigationView и drawerLayout
// слушатель клика для drawer layout.
private fun drawer_layout_listener() {

    drawer_layout.addDrawerListener(object : DrawerLayout.DrawerListener {
        override fun onDrawerStateChanged(newState: Int) {
        }

        override fun onDrawerSlide(drawerView: View?, slideOffset: Float) {
        }

        override fun onDrawerClosed(drawerView: View?) {
            supportActionBar!!.setHomeAsUpIndicator(resources.getDrawable(R.drawable.ic_menu_white_24dp))
        }

        override fun onDrawerOpened(drawerView: View?) {
            supportActionBar!!.setHomeAsUpIndicator(resources.getDrawable(R.drawable.ic_keyboard_backspace_white_24dp))
        }
    }
    )
}

// слушатель клика для элементов Navigation.
private fun setupNavigationView() {

    navigation.setNavigationItemSelectedListener(object : NavigationView.OnNavigationItemSelectedListener {
        override fun onNavigationItemSelected(item: MenuItem): Boolean {
            drawer_layout.closeDrawer(Gravity.START)
            when (item.itemId) {
                R.id.nav_all_folders -> {
                                 }
                R.id.nav_hidden_folders -> {
                                 }
            }
            return false
        }
    })
}

Добавим код в AlbumsFolderAdapter.kt для RecyclerView

Создаём файлы разметки для адаптера RecyclerView из MainActivity
// list_layout.xml
// layout file for RecyclerView adapter.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <android.support.v7.widget.CardView
        android:id="@+id/card_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="@dimen/five_dp"
        android:elevation="3dp"
        card_view:cardCornerRadius="@dimen/zero_dp">

        <RelativeLayout
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:background="?attr/selectableItemBackgroundBorderless">

            <ImageView
                android:id="@+id/thumbnail"
                android:layout_width="match_parent"
                android:layout_height="@dimen/album_cover_height"
                android:scaleType="centerCrop" />

            <TextView
                android:id="@+id/title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@id/thumbnail"
                android:paddingLeft="@dimen/ten_dp"
                android:paddingRight="@dimen/ten_dp"
                android:paddingTop="@dimen/ten_dp"
                android:text="Camera"
                android:textColor="@color/colorPrimaryText"
                android:textSize="@dimen/fifteen_dp" />

            <RelativeLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@id/title">

                <TextView
                    android:id="@+id/photoCount"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:paddingBottom="@dimen/five_dp"
                    android:paddingLeft="@dimen/ten_dp"
                    android:paddingRight="@dimen/six_dp"
                    android:textColor="@color/colorSecondaryText"
                    android:textSize="@dimen/twelve_dp" />
            </RelativeLayout>
        </RelativeLayout>
    </android.support.v7.widget.CardView>
</LinearLayout>
Создадим ещё класс, назовём файл с ним AlbumFoldersAdapter.kt — это будет адаптер для RecyclerView.


class AlbumFoldersAdapter(val albumList: ArrayList<Albums>, val context: Context, val options: RequestOptions, val glide: RequestBuilder<Bitmap>, val glideMain: RequestManager, val inOnItemClick: IOnItemClick) : RecyclerView.Adapter<AlbumFoldersAdapter.ViewHolder>() {

 override fun onViewRecycled(holder: ViewHolder?) {
 if (holder != null) {
 //glideMain.clear(holder.itemView.thumbnail)
 // glide.clear(holder.itemView.thumbnail)
 //Glide.get(context).clearMemory()
 // holder?.itemView?.thumbnail?.setImageBitmap(null)
 }// Glide.clear(holder?.itemView?.thumbnail)
 super.onViewRecycled(holder)

}

override fun onViewDetachedFromWindow(holder: ViewHolder) {
 if (holder != null) {
 // glideMain.clear(holder.itemView.thumbnail)
 //Glide.get(context).clearMemory()
 // holder?.itemView?.thumbnail?.setImageBitmap(null)
 
}

super.onViewDetachedFromWindow(holder)
 }

override fun getItemCount(): Int {
 return albumList.size
 }

override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
 holder?.bindItems(albumList.get(position), glide, options, inOnItemClick, albumList.get(position).isVideo)

holder?.itemView?.title?.setText(albumList.get(position).folderNames)
 if (albumList.get(position).isVideo)
 holder?.itemView?.photoCount?.setText("" + albumList.get(position).imgCount + " videos")
 else
 holder?.itemView?.photoCount?.setText("" + albumList.get(position).imgCount + " photos")
 }

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
 val v = LayoutInflater.from(parent.context).inflate(R.layout.list_layout, parent, false)
 return ViewHolder(v)
 }

class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

fun bindItems(albumList: Albums, glide: RequestBuilder<Bitmap>, options: RequestOptions, inOnItemClick: IOnItemClick, isVideo: Boolean) {
 glide.load(albumList.imagePath).apply { options }.thumbnail(0.4f)
 .into(itemView.thumbnail)

itemView.setOnClickListener(object : View.OnClickListener {
 override fun onClick(p0: View?) {
 inOnItemClick.onItemClick(albumList.folderNames, isVideo)
     }
  })
}}}
Добавим Interface для обработки клика в RecyclerView
interface IOnItemClick {
  fun onItemClick(position: String, isVideo: Boolean)
}

Итак, мы реализовали вывод всех папок с изображениями и самих изображений выбранной папки в recyclerView.

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

Создадим новую Activity AlbumsActivity.kt, ответственную за вывод изображений выбранной папки в RecyclerView.

Создадим файл разметки для AlbumsActivity.kt

// activity_album.xml

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.Toolbar
        android:id="@+id/my_album_toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"
        android:elevation="4dp"
        app:titleTextColor="@color/colorIcons" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rvAlbumSelected"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="?attr/actionBarSize"
        android:clipToPadding="false" />

</android.support.design.widget.CoordinatorLayout>
AlbumsActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_album)

    setSupportActionBar(my_album_toolbar)
    // Включим кнопку "Вверх"
    supportActionBar!!.setDisplayHomeAsUpEnabled(true)

    val folder_name = intent.getStringExtra("folder_name")
    supportActionBar!!.setTitle("" + folder_name)
    val isVideo = intent.getBooleanExtra("isVideo", false)
    init_ui_views(folder_name, isVideo)

}




var adapter: SingleAlbumAdapter? = null

private fun init_ui_views(folderName: String?, isVideo: Boolean?) {

    val options = RequestOptions()
            .diskCacheStrategy(DiskCacheStrategy.RESOURCE).override(160, 160).skipMemoryCache(true).error(R.drawable.ic_image_unavailable)
    val glide = Glide.with(this)
    val builder = glide.asBitmap()

        rvAlbumSelected.layoutManager = GridLayoutManager(this, 2)
    rvAlbumSelected?.setHasFixedSize(true)
    adapter = SingleAlbumAdapter(getAllShownImagesPath(this, folderName, isVideo), this, options, builder, glide, this)
    rvAlbumSelected?.adapter = adapter

    rvAlbumSelected?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
        }

        override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            when (newState) {
                RecyclerView.SCROLL_STATE_IDLE -> glide.resumeRequests()
                AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL, AbsListView.OnScrollListener.SCROLL_STATE_FLING -> glide.pauseRequests()
            }
        }
    }
    )
}

// Читаем все пути до изображений в выбранной папке.

private fun getAllShownImagesPath(activity: Activity, folderName: String?, isVideo: Boolean?): MutableList<String> {

    val uri: Uri
    val cursorBucket: Cursor
    val column_index_data: Int
    val listOfAllImages = ArrayList<String>()
    var absolutePathOfImage: String? = null

    val selectionArgs = arrayOf("%" + folderName + "%")

    uri = android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    val selection = MediaStore.Images.Media.DATA + " like ? "

    val projectionOnlyBucket = arrayOf(MediaStore.MediaColumns.DATA, MediaStore.Images.Media.BUCKET_DISPLAY_NAME)

    cursorBucket = activity.contentResolver.query(uri, projectionOnlyBucket, selection, selectionArgs, null)

    column_index_data = cursorBucket.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)

    while (cursorBucket.moveToNext()) {
        absolutePathOfImage = cursorBucket.getString(column_index_data)
        if (absolutePathOfImage != "" && absolutePathOfImage != null)
            listOfAllImages.add(absolutePathOfImage)
    }
    return listOfAllImages.asReversed()
}
Слушатель клика для Album Activity

override fun onItemClick(position: String, isVideo: Boolean) {
    val intent = Intent(this, PhotoActivity::class.java)
    intent.putExtra("folder_name", position)
    startActivity(intent)
}
RecyclerView adapter для AlbumActivity.kt
class SingleAlbumAdapter(val albumList: MutableList<String>, val context: Context, val options: RequestOptions, val glide: RequestBuilder<Bitmap>, val glideMain: RequestManager, val inOnItemClick: IOnItemClick) : RecyclerView.Adapter<SingleAlbumAdapter.ViewHolder>() {


    override fun onViewRecycled(holder: ViewHolder?) {
        if (holder != null) {
        }
        super.onViewRecycled(holder)
    }

    override fun onViewDetachedFromWindow(holder: ViewHolder) {
        if (holder != null) {

        }
        super.onViewDetachedFromWindow(holder)
    }

    override fun getItemCount(): Int {
        return albumList.size
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.bindItems(albumList.get(position), glide, options, inOnItemClick)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val v = LayoutInflater.from(parent.context).inflate(R.layout.list_single_album_layout, parent, false)
        return ViewHolder(v)
    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        fun bindItems(albumList: String, glide: RequestBuilder<Bitmap>, options: RequestOptions, inOnItemClick: IOnItemClick) {

            glide.load(albumList).apply { options }.thumbnail(0.4f)
                    .into(itemView.thumbnail)

            itemView.setOnClickListener(object : View.OnClickListener {
                override fun onClick(p0: View?) {
                    inOnItemClick.onItemClick(albumList, false)
                }
            })
        }
    }
}
Файл разметки для Single Album Adapter
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <android.support.v7.widget.CardView
        android:id="@+id/card_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="@dimen/five_dp"
        android:elevation="3dp"
        card_view:cardCornerRadius="@dimen/zero_dp">

        <RelativeLayout
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:background="?attr/selectableItemBackgroundBorderless">

            <ImageView
                android:id="@+id/thumbnail"
                android:layout_width="@dimen/album_cover_height"
                android:layout_height="@dimen/album_cover_height"
                android:scaleType="centerCrop" />
        </RelativeLayout>
    </android.support.v7.widget.CardView>
</LinearLayout>
Теперь добавим Detail Activity, выводящую единственное изображение

// SingleActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_photo)

    setSupportActionBar(toolbar)
    // Enable the Up button
    supportActionBar!!.setDisplayHomeAsUpEnabled(true)
    supportActionBar!!.setDisplayShowTitleEnabled(false)

    val folder_name = intent.getStringExtra("folder_name")
    Glide.with(this).load(folder_name).into(imageFullScreenView)

    Handler().postDelayed(Runnable
    {
        if (supportActionBar != null)
            appbar.animate().translationY(-appbar.bottom.toFloat()).setInterpolator(AccelerateInterpolator()).start()
        isAppBarShown = false
    }, 1500)

}




// activity_photo.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/black"
    tools:context="com.title_apps.canalpic.screens.detail.PhotoActivity">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#93000000">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:elevation="4dp"
            app:titleTextColor="@color/colorIcons" />
    </android.support.design.widget.AppBarLayout>


    <ImageView
        android:id="@+id/imageFullScreenView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center" />

</android.support.design.widget.CoordinatorLayout>
Резюмируя

Сегодня мы рассмотрели полный пример реализации приложения — галереи изображений под Android, а также освоили синтаксис Kotlin, классы данных, библиотеку Glide, RecyclerView и его адаптер, NavigationView, Drawer Layout и реализацию слушателей для них на Kotlin.

По материалам «How to Develop Android Image Gallery App in Kotlin Tutorial with Complete Source Code»

Разработка приложения-галереи под Android на Kotlin

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Разработка кросс-платформенного мобильного приложения на голом JSON

Разработка: Разработка кросс-платформенного мобильного приложения на голом JSON

Предыдущие несколько месяцев я посвятил работе над совершенно новым способом разработки нативных приложений для iOS и Android и назвал его Jasonette.

С Jasonette можно описать всю логику приложения всего в одном файле JSON-разметки. А если всё ваше приложение состоит из JSON, его можно загружать так же, как любые другие данные.

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

В видео ниже кратко описан весь процесс (на англ.):

Jasonette состоит из многих компонентов. Там есть функции, шаблоны, стили и многое другое, и всё это в JSON разметке. Вы можете писать супер-изощрённые нативные приложения в стиле [simple_tooltip content=’Model-View-Controller (Модель-Представление-Контроллер) — схема разделения данных приложения, пользовательского интерфейса и управляющей логики на три отдельных компонента: модель, представление и контроллер — таким образом, что модификация каждого компонента может осуществляться независимо.’]MVC[/simple_tooltip].

Сегодня мы разберём только «Представления»:

  1. Как Jasonette выражает различные кросс-платформенные шаблоны UI в JSON.
  2. Как реализованы внутренние преобразования JSON-в-Native.

Базовая структура

Издалека можно подумать, что Jasonette работает подобно браузеру. Но вместо того, чтобы работать с HTML и отрисовывать web-view, Jasonette загружает JSON и «на лету» собирает нативное представление.

Разметка JSON здесь самая обычная, но при этом она строится на нескольких стандартах. Во-первых, структура начинается с ключа $jason, который имеет двух потомков: head и body. Выглядит она так:

{
  "$jason": {
    "head": {
      .. метаданные документа ...
    },
    "body": {
      .. содержимое, выводимое на экран ..
    }
  }
}

Философия проектирования

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

  1. Список с вертикальной прокруткой
  2. Список с горизонтальной прокруткой
  3. Абсолютное позиционирование
  4. Сетка

Взглянем на первые три, ибо они самые распространённые.

1. Секции — описание списка с прокруткой

Списки с прокруткой это самый популярный способ построения интерфейса приложений. В Jasonette мы называем их sections.

Они бывают двух видов: вертикальные и горизонтальные.

Разработка кросс-платформенного мобильного приложения на голом JSON
Разработка кросс-платформенного мобильного приложения на голом JSON


Реализация — Вертикальные секции

Под iOS Jasonette реализует их с помощью UITableView. Под Android — с RecyclerView.


{
  "body": {
    "sections": [{
      "items": [
        {"type": "label", "text": "Item 1"},
        {"type": "label", "text": "Item 2"},
        {"type": "label", "text": "Item 3"}
      ]
    }]
  }
}

Под iOS эта JSON разметка создаст UITableView с тремя UITableViewCells, каждая из которых содержит UILabel, с соответствующими атрибутами.
Под Android будет создано RecyclerView с тремя элементами, каждый из которых это TextView, выводящий соответствующий элемент.
Всё это будет сконструировано программно, без какого-либо использования Storyboards (в iOS) или файлов макета XML (в Android).

Реализация — Горизонтальные секции

Синтаксически нет разницы с вертикальными секциями. Единственное, что мы изменили, это type в “horizontal”.


{
  "body": {
    "sections": [{
      "type": "horizontal",
      "items": [
        {"type": "label", "text": "Item 1"},
        {"type": "label", "text": "Item 2"},
        {"type": "label", "text": "Item 3"}
      ]
    }]
  }
}
2. Элементы — Описываем макет каждого элементы прокрутки

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

Элемент может быть:

  • Единичным компонентом типа label (метка), image (картинка), button (кнопка), textarea (текстовое поле) и т.д.
  • Комбинацией любых этих элементов

К счастью, iOS и Android имеют похожие системы построения представлений, UIStackView и LinearLayout, соответственно. И эти системы в свою очередь похожи на CSS Flexbox, что облегчает нам работу. И вдобавок к этому, нативные системы представлений бесконечно компонуемы — как показано ниже, вы можете создать вертикальный макет, горизонтальный макет, а также скомпоновать и горизонтальный и вертикальный в одном, и так до бесконечности.

Разработка кросс-платформенного мобильного приложения на голом JSON
Разработка кросс-платформенного мобильного приложения на голом JSON

Разработка кросс-платформенного мобильного приложения на голом JSON

Для создания вертикального макета, выставим type как vertical, затем настроим остальные компоненты:


{
  "items": [{
    "type": "vertical",
    "components": [
      {
        "type": "label",
        "text": "First"
      }, 
      {
        "type": "label",
        "text": "Second"
      }, 
      {
        "type": "label",
        "text": "Third"
      }
    ]
  }]
}

Здесь то же самое. Просто установим type в horizontal:


{
  "items": [{
    "type": "horizontal",
    "components": [
      {
        "type": "image",
        "url": "http://i.giphy.com/LXONhtCmN32YU.gif"
      }, 
      {
        "type": "label",
        "text": "Rick"
      }
    ]
  }]
}

Встроить один макет в другой так же просто:


{
  "items": [{
    "type": "horizontal",
    "components": [
      {
        "type": "image",
        "url": "http://i.giphy.com/LXONhtCmN32YU.gif"
      }, 
      {
        "type": "vertical",
        "components": [{
          "type": "label",
          "text": "User"
        }, {
          "type": "label",
          "text": "Rick"
        }]
      }
    ]
  }]
}

Чтобы не усложнять понимание, я пока не упоминал про стилизование элементов приложения, но это делается очень просто. Всё, что вам нужно для этого, это добавить объект style с описанием атрибутов font (шрифт), size (размер), width (ширина), height (высота), color (цвет), background (фон), corner_radius (угловой радиус), opacity (прозрачность) и т.п.

3. Слои — абсолютное позиционирование

Иногда вам может понадобиться разместить элементы в определённой части экрана без прокрутки и перемещений. Jasonette поддерживает такое размещение в layers.

На текущий момент в слое можно разместить только два типа дочерних объектов: image и label. Вы можете разместить их в любой части экрана. Ниже пример этого:

Разработка кросс-платформенного мобильного приложения на голом JSON

В этом примере у нас созданы две метки (для температуры и состояния погоды) и картинка (иконка камеры). Они размещены в своих координатах и не двигаются:


{
  "$jason": {
    "body": {
      "style": {
        "background": "camera"
      },
      "layers": [
        {
          "type": "label",
          "text": "22°C",
          "style": {
            "font": "HelveticaNeue-Light",
            "size": "20",
            "top": "50",
            "left": "50%-100",
            "width": "200",
            "align": "center"
          }
        },
        {
          "type": "label",
          "text": "few clouds",
          "style": {
            "font": "HelveticaNeue",
            "size": "15"
          }
        },
        {
          "type": "image",
          "url": "https://s3.amazonaws.com/.../camera%402x.png",
          "style": {
            "bottom": "100",
            "width": "30",
            "color": "#ffffff",
            "right": "30"
          }
        }
      ]
    }
  }
}

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

Вот ещё примеры того, что построено в Jasonette:

Разработка кросс-платформенного мобильного приложения на голом JSON
Разработка кросс-платформенного мобильного приложения на голом JSON
За пределами представлений

Прочитав всё это, вы можете подумать:

  • «Ух ты, круто! Я должен это попробовать!» или
  • «Да, я конечно могу сделать приложение на поиграться, но реальное — никогда!»

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

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

Что ещё возможно?

Вам обязательно нужен сервер, хранящий JSON, а в остальном Jasonette совершенно автономна. И этот JSON может прилетать отовсюду: с локального устройства, с удаленных серверов, да хоть с raspberry pi!

  1. У вас есть веб-приложение? Тогда вы с лёгкостью построите мобильное приложение, вызывая серверный API
  2. Вам можно вообще не думать о сервере. Храните JSON файл на Pastebin или Github!
  3. Сконвертируйте любой веб-сайт в приложение. В Jasonette есть мощный парсер HTML-в-JSON на базе библиотеки cheerio, которая позволяет преобразовать любой HTML в объект JSON. Ну и, конечно, вы можете сами сформировать нужный JSON.

Ещё немного примеров:

Разработка кросс-платформенного мобильного приложения на голом JSON

Фото-приложение, делающее снимок камерой устройства и выкладывающая его в S3, затем она создаёт запись в ленте новостей на своём сервере:

Разработка кросс-платформенного мобильного приложения на голом JSON

Приложение Eliza Chatbot для iOS и Android на базе Node.js:

Приложение для микро-блоггинга:

Разработка кросс-платформенного мобильного приложения на голом JSON
Разработка кросс-платформенного мобильного приложения на голом JSON

Приложение, конвертирующее сайт HTML в JSON структуру, а затем в мобильное приложение:

Разработка кросс-платформенного мобильного приложения на голом JSON
Заключение

Jasonette это пока молодой проект. Версия для iOS вышла в конце 2016 г, а для Android ещё немного позже.

Но уже сейчас у неё есть огромное сообщество разработчиков и она активно развивается.

Звучит круто? Тогда Jasonette будет очень рад вам!

Источник

Разработка кросс-платформенного мобильного приложения на голом JSON

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).

Создание мобильных приложений с Cordova и Vue.js

Cordova это фреймворк, с которым вы можете создавать мобильные приложения с использование веб-технологий, таких как HTML, Javascript и CSS. Это позволяет создавать приложения сразу для нескольких платформ (к примеру, на Android и iOS) с общей кодовой базой. Несмотря на то, что вам всё ещё нужны платформо-зависимые технологии, типа Android SDK и XCode для сборки, вы можете создавать приложения без использования нативного Android- или iOS-кода.

А так как наш код это HTML и Javascript, мы легко можем использовать любимые Javascript библиотеки вроде Vue.js с Cordova.

И сегодня мы рассмотрим пример создания мобильного приложения с Cordova и Vue.js.

Исходный код проекта можно взять здесь.

Подготовка

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

  • Скачайте Node.js
  • Установите Cordova: npm install -g cordova
  • Ознакомьтесь с основами Vue.js
Создание проекта Cordova

Создадим проект и назовём его RandomWord:


cordova create RandomWord
cd RandomWord

Это создаст иерархию директорий:

Создание мобильных приложений с Cordova и Vue.js


  • config.xml — содержит информацию о приложении, используемых плагинах и платформах
  • platforms — содержит библиотеки Cordova для целевых платформ, на которых приложение будет запущено
  • plugins — содержит библиотеки плагинов Cordova, используемые в приложении. Они предоставляют приложению доступ к устройству: к камере, состоянию батареи и т.п.
  • www — содержит исходный код приложения на HTML, Javascript и CSS
  • hooks — содержит скрипты для настройки системы сборки приложения

Добавим платформу Android:

cordova platform add android --save

Эта команда добавит библиотеку платформы Android в папку platforms (platforms/android).


...
    <engine name="android" spec="~5.2.1" />
</widget>

Проверим установленные зависимости для сборки/запуска приложений Android с Cordova:

cordova requirements

Собираем наше приложение под Android:

cordova build android

Подключите ваш телефон к компьютеру и запустите приложение:

cordova run android

Если не подключать устройство, Cordova запустит приложение в эмуляторе. Наше приложение пока лишь показывает картинку и надпись:

Создание мобильных приложений с Cordova и Vue.js

Для построения приложения под iOS нужно выполнить те же шаги, заменив в командах android на ios. Также есть вариант использовать ваш браузер вместо мобильного устройства, установив платформу browser.

Изменим немного файл config.xml:


<?xml version='1.0' encoding='utf-8'?>
<widget id="io.coligo.randomword" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
    <name>RandomWord</name>
    <description>
        A mobile app for generating a random word.
    </description>
    <author email="michaelviveros@gmail.com" href="http://www.michaelviveros.com/">
        Michael Viveros
    </author>
    ...
Добавляем Vue.js

Добавим Vue.js CDN в конец файла www/index.html:


...
        <script type="text/javascript" src="cordova.js"></script>
        <script src="http://cdn.jsdelivr.net/vue/1.0.16/vue.js"></script>
        <script type="text/javascript" src="js/index.js"></script>
    </body>
</html>

Для того, чтобы приложение могло вызывать библиотеку Vue.js, добавим строчку в конец мета-тега Content Security Policy (CSP) в www/index.html:

; script-src 'self' http://cdn.jsdelivr.net/vue/1.0.16/vue.js 'unsafe-eval'

CSP позволяет создать список разрешённых источников и разрешает браузеру обрабатывать контент только из этих источников.

Часть мета-тега CSP script-src отвечает за то, откуда будут браться скрипты, исполняемые нашим приложением.

  • ’self’ — разрешены скрипты только из этого же источника, например, www/js/index.js
  • http://cdn.jsdelivr.net/vue/1.0.16/vue.js — разрешена библиотека Vue.js
  • ’unsafe-eval’ — разрешено выполнение небезопасного кода, так как некоторый код из Vue.js использует строки для генерации функций

Мета-тег CSP должен выглядеть так:

После замены кода в теле www/index.html некоторым кодом Vue.js, этот файл должен стать таким:


<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Security-Policy" content="default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *; script-src 'self' http://cdn.jsdelivr.net/vue/1.0.16/vue.js 'unsafe-eval'">
        <meta name="format-detection" content="telephone=no">
        <meta name="msapplication-tap-highlight" content="no">
        <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
        <link rel="stylesheet" type="text/css" href="css/index.css">
        <title>Random Word</title>
    </head>
    <body>
        <div id="vue-instance" class="app">
            <h1>Random Word</h1>
            <button id="btn-get-random-word" @click="getRandomWord">Get Random Word</button>
            <p>{{ randomWord }}</p>
        </div>
        <script type="text/javascript" src="cordova.js"></script>
        <script src="http://cdn.jsdelivr.net/vue/1.0.16/vue.js"></script>
        <script type="text/javascript" src="js/index.js"></script>
    </body>
</html>

Теперь добавим немного Javascript для генерации и вывода случайного слова.

В www/js/index.js уже есть код, он может сменить цвет фона или заголовок, при получении приложением события deviceready. И нам не нужно пока лезть в эти методы. Однако, попробуйте сами разобраться с методом bindEvents для выполнения различных действий на различных этапах жизненного цикла приложения.

Мы добавим новый метод в файл www/js/index.js, назовём его setupVue, он будет создавать новый объект Vue и привязывать его к блоку random word. Новый объект Vue будет содержать метод getRandomWord, извлекающий случайное слово из списка слов при нажатии кнопки Get Random Word.


var app = {
    initialize: function() {
        this.bindEvents();
        this.setupVue();
    },
    ...
    setupVue: function() {
        var vm = new Vue({
            el: "#vue-instance",
            data: {
                randomWord: '',
                words: [
                    'formidable',
                    'gracious',
                    'daft',
                    'mundane',
                    'onomatopoeia'
                ]
            },
            methods: {
                getRandomWord: function() {
                    var randomIndex = Math.floor(Math.random() * this.words.length);
                    this.randomWord = this.words[randomIndex];
                }
            }
        });
    }
};

app.initialize();

После удаления ненужного кода, файл www/js/index.js будет таким:


var app = {
    initialize: function() {
        this.bindEvents();
        this.setupVue();
    },
    bindEvents: function() {
        document.addEventListener('deviceready', this.onDeviceReady, false);
    },
    onDeviceReady: function() {
        app.receivedEvent('deviceready');
    },
    receivedEvent: function(id) {
        console.log('Received Event: ' + id);
    },
    setupVue: function() {
        var vm = new Vue({
            el: "#vue-instance",
            data: {
                randomWord: '',
                words: [
                    'formidable',
                    'gracious',
                    'daft',
                    'mundane',
                    'onomatopoeia'
                ]
            },
            methods: {
                getRandomWord: function() {
                    var randomIndex = Math.floor(Math.random() * this.words.length);
                    this.randomWord = this.words[randomIndex];
                }
            }
        });
    }
};

app.initialize();

Соберём приложение, подключим телефон и запустим:

cordova build android
cordova run android

Приложение теперь выглядит примерно так:

Создание мобильных приложений с Cordova и Vue.js
HTTP запросы с vue-resource

Вместо того, чтобы использовать заданный список слов в нашем приложении, используем для этого специальный веб-сервис, например, этот: Wordnik Random Word API

В конец мета-тега CSP добавьте следующий код для разрешения работать с этим ресурсом:

; connect-src http://api.wordnik.com:80/v4/words.json/randomWord

В connect-src мы указали приложению, куда разрешено делать http-запросы.

Добавим библиотеку vue-resource для создание таких запросов.

index.html немного изменится:


<!DOCTYPE html>
...
        <meta http-equiv="Content-Security-Policy" content="default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *; script-src 'self' http://cdn.jsdelivr.net/vue/1.0.16/vue.js https://cdn.jsdelivr.net/vue.resource/0.7.0/vue-resource.min.js 'unsafe-eval'; connect-src http://api.wordnik.com:80/v4/words.json/randomWord">
...
        <script src="http://cdn.jsdelivr.net/vue/1.0.16/vue.js"></script>
        <script src="https://cdn.jsdelivr.net/vue.resource/0.7.0/vue-resource.min.js"></script>
        <script type="text/javascript" src="js/index.js"></script>
    </body>
</html>

Отредактируем файл www/js/index.js для использования веб-сервиса:


...
    setupVue: function() {
        var vm = new Vue({
            el: "#vue-instance",
            data: {
                randomWord: ''
            },
            methods: {
                getRandomWord: function() {
                    this.randomWord = '...';
                    this.$http.get(
                        'http://api.wordnik.com:80/v4/words.json/randomWord?api_key=a2a73e7b926c924fad7001ca3111acd55af2ffabf50eb4ae5'
                    ).then(function (response) {
                        this.randomWord = response.data.word;
                    }, function (error) {
                        alert(error.data);
                    });
                }
            }
        });
    }
};

app.initialize();

Соберём и запустим приложение:

cordova build android
cordova run android

Приложение выглядит как раньше, однако слова берутся уже с помощью Random word API.

Компоненты Vue

Vueify это библиотека для Vue.js, помогающая разделить интерфейс на небольшие компоненты, каждый со своим HTML, JavaScript и CSS. Приложение становится более модульным и вы можете вызывать отдельные его компоненты в иерархическом порядке.

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

Так должна выглядеть структура папок после добавления Vue Component:

Создание мобильных приложений с Cordova и Vue.js



Создадим компонент, вызывающий наш метод получения слова в www/js/random-word.vue:


<template>
  <div class="app">      
    <h1>Random Word</h1>
    <button id="btn-get-random-word" @click="getRandomWord">Get Random Word</button>
    <p>{{randomWord}}</p>
  </div>
</template>

<script>
export default {
  data () {
    return {
      randomWord: ''
    }
  },
  methods: {
    getRandomWord: function() {
        this.randomWord = '...';
        this.$http.get(
            'http://api.wordnik.com:80/v4/words.json/randomWord?api_key=a2a73e7b926c924fad7001ca3111acd55af2ffabf50eb4ae5'
        ).then(function (response) {
            this.randomWord = response.data.word;
        }, function (error) {
            alert(error.data);
        });
    }
  }
}
</script>


Разметка HTML из файла www/index.html теперь находится в теге template, а Javascript код из www/js/index.js находится в теге script файла random-word.vue.

Создадим новый объект Vue, содержащий этот компонент в новом файле www/js/main.js:


var Vue = require('vue');
var VueResource = require('vue-resource');
var RandomWord = require('./random-word.vue');

Vue.use(VueResource);

var vm = new Vue({
  el: 'body',
  components: {
    'random-word': RandomWord
  }
});

Для склеивания компонентов мы используем browserify и vueify для создания файла bundle.js. Создадим новую папку scripts и в ней файл vueify-build.js.

scripts/vueify-build.js будет таким:


var fs = require('fs');
var browserify = require('browserify');
var vueify = require('vueify');

browserify('www/js/main.js')
  .transform(vueify)
  .bundle()
  .pipe(fs.createWriteStream('www/js/bundle.js'))

Раньше мы вызывали библиотеки Vue.js с CDN в www/index.html, но сейчас www/js/main.js использует javascript для этого. Создадим файл package.json для синхронизации всех зависимостей:


{
  "name": "random-word",
  "version": "1.0.0",
  "description": "A mobile app for generating a random word",
  "main": "index.js",
  "dependencies": {
    "browserify": "~13.0.1",
    "vue": "~1.0.24",
    "vue-resource": "~0.7.4",
    "vueify": "~8.5.4",
    "babel-core": "6.9.1",
    "babel-preset-es2015": "6.9.0",
    "babel-runtime": "6.9.2",
    "babel-plugin-transform-runtime": "6.9.0",
    "vue-hot-reload-api": "2.0.1"
  },
  "author": "Michael Viveros",
  "license": "Apache version 2.0"
}

Установить все зависимости проекта теперь можно одной командой:

npm install
Добавим хук в конец файла config.xml, чтобы Cordova знала о необходимости склеивания компонента перед сборкой приложения:


...
    <hook type="before_compile" src="scripts/vueify-build.js" />
</widget>


Мы помним, что scripts/vueify-build.js создаст собранный компонент и положит его в www/js/bundle.js

Добавим вызов компонента в тело www/index.html, создав тег random-word и тег script, вызывающий наш склеенный ранее компонент.


...
        <link rel="stylesheet" type="text/css" href="css/index.css">
        <title>Random Word</title>
    </head>
    <body>
        <random-word></random-word>
        <script src="js/bundle.js"></script>

        <script type="text/javascript" src="js/index.js"></script>
        <script type="text/javascript" src="cordova.js"></script>
    </body>
</html>

Помните, что тег link в www/index.html задаёт CSS приложения, а тег div в www/js/random-word.vue использует класс «app» из CSS.

Поскольку мы перенесли логику в другое место, теперь мы можем удалить метод setupVue из www/js/index.js:


var app = {
    initialize: function() {
        this.bindEvents();
    },
    bindEvents: function() {
        document.addEventListener('deviceready', this.onDeviceReady, false);
    },
    onDeviceReady: function() {
        app.receivedEvent('deviceready');
    },
    receivedEvent: function(id) {
        console.log('Received Event: ' + id);
    }
};

app.initialize();

Соберём и запустим приложение:

cordova build android
cordova run android
Заключение

Разрабатывать мобильные приложения с Cordova очень просто. А с Vue.js вы можете использовать огромное количество её функций в мобильных приложениях.

Написано по материалам Building a Mobile App with Cordova and Vue.js by Michael Viveros

Создание мобильных приложений с Cordova и Vue.js

Разработчик: java, kotlin, c#, javascript, dart, 1C, python, php.

Пишите: @ighar. Buy me a coffee, please :).