Kivy и Flutter два фреймворка с открытым исходным кодом для кроссплатформенной разработки.
Flutter:
- создан компанией Google и выпущенный в 2017 году;
- в качестве языка программирования использует Dart;
- не использует нативные компоненты, рисуя весь интерфейс внутри собственного графического движка;
Kivy:
- создан сообществом Kivy в 2010 году;
- в качестве языка программирования использует Python и собственный декларативный язык для разметки UI элементов KV Language;
- не использует нативные компоненты, рисуя весь интерфейс с помощью OpenGL ES 2.0 и SDL2;
Недавно на просторах Ютуба наткнулся на видео демонстрацию Flutter приложения Facebook Desktop Redesign built with Flutter Desktop. Отличное демонстрационное приложение в стиле material design! И поскольку я один из разработчиков библиотеки KivyMD (набор material компонентов для фреймворка Kivy) мне стало интересно, насколько просто будет сделать такой же красивый интерфейс. К счастью автор оставил ссылку на репозиторий проекта.
Как вы думаете, какое приложение на вышеприведенных скриншотах написано с использованием Flutter и какое с помощью Kivy? Ответить сходу трудно, поскольку ярко выраженных отличий нет. Единственное, что сразу бросается в глаза (нижний скриншот) в Kivy все еще нет нормального сглаживания. И это грустно, но не критично. Сравнивать мы будем отдельные элементы приложения и их исходный код на Dart (Flutter) и Python/KV language (Kivy).
Посмотрим теперь как выглядят компоненты изнутри
StoryCard
Kivy
Разметка карточки на языке KV-Language:
Базовый Python класс:
from kivy.properties import StringPropertyfrom kivymd.uix.relativelayout import MDRelativeLayoutclass StoryCard(MDRelativeLayout): avatar = StringProperty() story = StringProperty() name = StringProperty() def on_parent(self, *args): if not self.avatar: self.remove_widget(self.ids.avatar)
Flutter:
import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';class Story extends StatefulWidget { final String name; final String avatar; final String story; const Story({ Key key, this.name, this.avatar, this.story, }) : super(key: key); @override _StoryState createState() => _StoryState();}class _StoryState extends State<Story> { @override Widget build(BuildContext context) { return Container( width: 150, margin: const EdgeInsets.only(top: 30), decoration: BoxDecoration( borderRadius: BorderRadius.circular(30), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 20, offset: Offset(0, 10), ), ], ), child: Stack( overflow: Overflow.visible, fit: StackFit.expand, children: [ ClipRRect( borderRadius: BorderRadius.circular(30), child: Image.network( widget.story, fit: BoxFit.cover, ), ), if (widget.avatar != null) Positioned.fill( top: -30, child: Align( alignment: Alignment.topCenter, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(30), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.4), blurRadius: 5, offset: Offset(0, 3), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(30), child: Image.network( widget.avatar, fit: BoxFit.cover, width: 60, height: 60, ), ), ), ), ), if (widget.avatar != null) Positioned.fill( child: Align( alignment: Alignment.bottomCenter, child: Row( children: [ Expanded( child: Container( padding: const EdgeInsets.all(15), decoration: BoxDecoration( borderRadius: BorderRadius.circular(30), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black, ], ), ), child: widget.name != null ? Text( widget.name, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ) : SizedBox(), ), ), ], ), ), ), ], ), ); }}
Как видим, код на Python и KV-Language получается вдвое короче. Исходный код проекта на Python/Kivy, который рассматривается в этой статье, имеет общий размер 31 килобайт. 3 килобайта из этого объема приходится на Python код, остальное KV-Language. Исходный код на Flutter 54 килобайт. Впрочем, здесь удивляться, кажется, нечему Python один их самый лаконичных языков программирования в мире.
Мы не будем спорить о том, что лучше: описывать UI при помощи DSL языков или прямо в коде. В Kivy, кстати, также можно строить виджеты Python кодом, но это не очень хорошее решение.
TopBar
Flutter:
Kivy:
Реализация этого бара, включая анимацию, на Python/Kivy заняла всего 88 строчек кода. На Dart/Flutter 325 строк и 9 килобайт на диске. Посмотрим, что представляет из себя этот виджет:
Лого, три таба, аватар, три таба и один таб кнопка настроек. Реализация таба с анимированным индикатором:
Анимация индикатора и смена типа курсора мыши реализована в Python файле в одноименном с правилом разметки классе:
from kivy.animation import Animationfrom kivy.properties import StringProperty, BooleanPropertyfrom kivy.core.window import Windowfrom kivymd.uix.boxlayout import MDBoxLayoutfrom kivymd.uix.behaviors import FocusBehaviorclass Tab(FocusBehavior, MDBoxLayout): icon = StringProperty() active = BooleanProperty(False) def on_enter(self): Window.set_system_cursor("hand") def on_leave(self): Window.set_system_cursor("arrow") def on_active(self, instance, value): Animation( opacity=value, width=self.width if value else 0, d=0.25, t="in_sine" if value else "out_sine", ).start(self.ids.separator)
Мы просто анимируем ширину и opacity индикатора в зависимости от состояния кнопки (active). Состояние кнопки устанавливается в главном классе экрана приложения:
class FacebookDesktop(ThemableBehavior, MDScreen): def set_active_tab(self, instance_tab): for widget in self.ids.tab_box.children: if issubclass(widget.__class__, MDBoxLayout): if widget == instance_tab: widget.active = True else: widget.active = False
Подробнее об анимации а Kivy:
Материальный дизайн. Создание анимаций в Kivy
Разработка мобильных приложений на Python. Создание анимаций в Kivy. Part 2
Реализация на Dart/Flutter.
Поскольку кода очень много, я спрятал все под спойлеры:
import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';class AppLogo extends StatelessWidget { @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: Colors.blue.withOpacity(.6), blurRadius: 5, spreadRadius: 1, ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.asset( 'assets/images/facebook_logo.jpg', width: 30, height: 30, ), ), ); }}
import 'package:flutter/material.dart';import 'package:flutter/rendering.dart';import 'package:flutter/widgets.dart';class TopBarAvatar extends StatefulWidget { @override _TopBarAvatarState createState() => _TopBarAvatarState();}class _TopBarAvatarState extends State<TopBarAvatar> with SingleTickerProviderStateMixin { Animation<Color> _animation; AnimationController _animationController; @override void initState() { _animationController = AnimationController( vsync: this, duration: Duration(milliseconds: 150), ); _animation = ColorTween( begin: Colors.grey.withOpacity(.4), end: Colors.blue.withOpacity(.6), ).animate(_animationController); _animation.addListener(() { setState(() {}); }); super.initState(); } @override Widget build(BuildContext context) { return MouseRegion( onHover: (event) { setState(() { _animationController.forward(); }); }, onExit: (event) { setState(() { _animationController.reverse(); }); }, cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), boxShadow: [ BoxShadow( color: _animation.value, blurRadius: 10, spreadRadius: 0, ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(15), child: Image.asset( 'assets/images/avatar.jpg', width: 50, height: 50, ), ), ), ), ); }}
import 'package:flutter/material.dart';import 'package:flutter/rendering.dart';import 'package:flutter/widgets.dart';class TopBarButton extends StatefulWidget { final IconData icon; final bool isActive; final Function onTap; const TopBarButton({ Key key, this.icon, this.isActive = false, this.onTap, }) : super(key: key); @override _TopBarButtonState createState() => _TopBarButtonState();}class _TopBarButtonState extends State<TopBarButton> with SingleTickerProviderStateMixin { Animation<Color> _animation; AnimationController _animationController; @override void initState() { _animationController = AnimationController( vsync: this, duration: Duration(milliseconds: 150), ); _animation = ColorTween( begin: Colors.grey.withOpacity(.6), end: Colors.blue.withOpacity(.6), ).animate(_animationController); _animation.addListener(() { setState(() {}); }); super.initState(); } @override void didUpdateWidget(TopBarButton oldWidget) { if (widget.isActive) { _animationController.forward(); } else { _animationController.reverse(); } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return GestureDetector( onTap: widget.onTap, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( height: 80, child: Stack( alignment: Alignment.center, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 30), child: Icon( widget.icon, color: _animation.value, ), ), Positioned( bottom: -1, child: Align( alignment: Alignment.bottomCenter, child: AnimatedContainer( duration: Duration(milliseconds: 50), curve: Curves.easeInOut, decoration: BoxDecoration( color: _animation.value, borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( color: _animation.value, blurRadius: 5, offset: Offset(0, 2), ), ], ), width: widget.isActive ? 50 : 0, height: 4, ), ), ), ], ), ), ), ); } @override void dispose() { _animationController.dispose(); super.dispose(); }}
import 'package:facebook_desktop/screens/home/components/top_bar/app_logo.dart';import 'package:facebook_desktop/screens/home/components/top_bar/avatar.dart';import 'package:facebook_desktop/screens/home/components/top_bar/button.dart';import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';import 'package:flutter_feather_icons/flutter_feather_icons.dart';class TopBar extends StatefulWidget { @override _TopBarState createState() => _TopBarState();}class _TopBarState extends State<TopBar> { int _selectedPage = 0; @override Widget build(BuildContext context) { return Container( color: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 30, ), child: Row( children: [ Expanded( flex: 1, child: Align( alignment: Alignment.centerLeft, child: AppLogo(), ), ), Expanded( flex: 6, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TopBarButton( icon: FeatherIcons.home, isActive: _selectedPage == 0, onTap: () { setState(() { _selectedPage = 0; }); }, ), TopBarButton( icon: FeatherIcons.youtube, isActive: _selectedPage == 1, onTap: () { setState(() { _selectedPage = 1; }); }, ), TopBarButton( icon: FeatherIcons.grid, isActive: _selectedPage == 2, onTap: () { setState(() { _selectedPage = 2; }); }, ), TopBarAvatar(), TopBarButton( icon: FeatherIcons.users, isActive: _selectedPage == 3, onTap: () { setState(() { _selectedPage = 3; }); }, ), TopBarButton( icon: FeatherIcons.zap, isActive: _selectedPage == 4, onTap: () { setState(() { _selectedPage = 4; }); }, ), TopBarButton( icon: FeatherIcons.smile, isActive: _selectedPage == 5, onTap: () { setState(() { _selectedPage = 5; }); }, ), ], ), ), Expanded( flex: 1, child: Align( alignment: Alignment.centerRight, child: IconButton( color: Colors.grey.withOpacity(.6), icon: Icon(FeatherIcons.settings), onPressed: () {}, ), ), ), ], ), ); }}
ChatCard (Kivy, Flutter)
Анимация сдвига карточки происходит относительно родительского виджета (parent) при получении событий фокуса и анфокуса (on_enter, on_leave):
on_enter: Animation(x=root.parent.x + dp(12), d=0.4, t="out_cubic").start(root)on_leave: Animation(x=root.parent.x + dp(24), d=0.4, t="out_cubic").start(root)
И базовый класс Python:
from kivy.core.window import Windowfrom kivy.properties import StringPropertyfrom FacebookDesktop.components.cards.fake_card import FakeCardclass ChatCard(FakeCard): avatar = StringProperty() text = StringProperty() name = StringProperty() def on_enter(self): Window.set_system_cursor("hand") def on_leave(self): Window.set_system_cursor("arrow")
Реализация Python/Kivy 60 строк кода, реализация Dart/Flutter 182 строки кода.
import 'package:ezanimation/ezanimation.dart';import 'package:facebook_desktop/components/user_tile.dart';import 'package:flutter/material.dart';import 'package:flutter/rendering.dart';import 'package:flutter_feather_icons/flutter_feather_icons.dart';class ChatCard extends StatefulWidget { final String image; final String name; final String message; final EdgeInsets padding; const ChatCard({ Key key, this.image, this.name, this.message, this.padding, }) : super(key: key); @override _ChatCardState createState() => _ChatCardState();}class _ChatCardState extends State<ChatCard> { EzAnimation _animation; @override void initState() { _animation = EzAnimation( 0.0, -5.0, Duration(milliseconds: 200), curve: Curves.easeInOut, context: context, ); _animation.addListener(() { setState(() {}); }); super.initState(); } @override Widget build(BuildContext context) { return Transform.translate( offset: Offset(_animation.value, 0), child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (event) { _animation.start(); }, onExit: (event) { _animation.reverse(); }, child: Padding( padding: widget.padding ?? const EdgeInsets.all(15), child: Container( width: 250, padding: const EdgeInsets.all(15), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(.1), blurRadius: 15, offset: Offset(0, 8), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ UserTile( name: widget.name, image: widget.image, trailing: Icon( FeatherIcons.messageSquare, color: Colors.blue, size: 14, ), ), SizedBox( height: 10, ), Text( widget.message, style: TextStyle(color: Colors.grey, fontSize: 12), maxLines: 3, overflow: TextOverflow.ellipsis, ), ], ), ), ), ), ); } @override void dispose() { _animation.dispose(); super.dispose(); }}
import 'package:facebook_desktop/screens/home/components/section.dart';import 'package:flutter/material.dart';class UserTile extends StatelessWidget { final String name; final String image; final Widget trailing; const UserTile({ Key key, this.name, this.image, this.trailing, }) : super(key: key); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( margin: const EdgeInsets.only(right: 10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(.1), blurRadius: 5, offset: Offset(0, 2), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(5), child: Image( image: NetworkImage( image, ), fit: BoxFit.cover, height: 50, width: 50, ), ), ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SectionTitle( title: name, ), SizedBox( height: 5, ), Text( '12 min ago', style: TextStyle(color: Colors.grey), ), ], ), if (trailing != null) Expanded( child: Align( alignment: Alignment.topRight, child: trailing ), ), ], ); }}
Но не все так просто, как кажется. В процессе я обнаружил, что в библиотеке KivyMD отсутствуют кнопки с типом badge. В проекте на Flutter, кстати, тоже использовались кастомные кнопки. Поэтому для создания правой панели инструментов пришлось сделать такие кнопки самостоятельно.
Базовый Python класс:
from kivy.properties import StringPropertyfrom kivymd.uix.relativelayout import MDRelativeLayoutclass BadgeButton(MDRelativeLayout): icon = StringProperty() text = StringProperty()
И уже создать левую панель инструментов:
Даже учитывая, что мне пришлось создавать кастомные кнопки типа badge, код левой панели инструментов на Python/Kivy получился короче 58 строк кода, реализация на Dart/Flutter 97 строк.
import 'package:flutter/material.dart';class LeftBarButton extends StatelessWidget { final IconData icon; final String badge; const LeftBarButton({ Key key, this.icon, this.badge, }) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector( child: Stack( children: [ Container( padding: const EdgeInsets.all(10), child: Icon( icon, color: Colors.grey.withOpacity(.6), ), ), if (badge != null) Positioned( top: 5, right: 2, child: Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( borderRadius: BorderRadius.circular(100), color: Colors.blue, ), child: Text( badge, style: TextStyle( color: Colors.white, fontSize: 10, ), ), ), ) ], ), ); }}
import 'package:facebook_desktop/screens/home/left_bar/button.dart';import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';import 'package:flutter_feather_icons/flutter_feather_icons.dart';class LeftBar extends StatelessWidget { @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.all(30), padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(50), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(.1), blurRadius: 2, offset: Offset(0, 4), ) ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ LeftBarButton( icon: FeatherIcons.mail, badge: '10', ), SizedBox( height: 5, ), LeftBarButton( icon: FeatherIcons.search, ), SizedBox( height: 5, ), LeftBarButton( icon: FeatherIcons.bell, badge: '20', ), ], ), ); }}
Безусловно я не умаляю достоинств фреймворка Flutter. Инструмент замечательный! Я всего лишь хотел показать Python разработчикам, что они могут делать те же самые вещи, что и во Flutter, но на их любимом языке программирования с помощью фреймворка Kivy и библиотеки KivyMD. Что касается мобильных платформ, то здесь стоит признать, что Flutter превосходит Kivy в скорости работы. Но это уже уже другая статья Ссылка на репозиторий проекта Facebook Desktop Redesign built with Flutter Desktop в реализации Python/Kivy/KivyMD.