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 StringProperty
from kivymd.uix.relativelayout import MDRelativeLayout
class 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 Animation
from kivy.properties import StringProperty, BooleanProperty
from kivy.core.window import Window
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.behaviors import FocusBehavior
class 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 Window
from kivy.properties import StringProperty
from FacebookDesktop.components.cards.fake_card import FakeCard
class 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 StringProperty
from kivymd.uix.relativelayout import MDRelativeLayout
class 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.
ookami_kb
Очень притянуто за уши. На флаттере так никто в здравом уме писать не будет, все эти виджеты можно сделать гораздо компактнее. Вы в него либо не умеете, либо специально понаписали этой индусской лапши.
Например, тот же
TopBar
можно переписать так:KivyMD Автор
Я и не говорил, что «умею во Flutter». И лично я ничего не писал. Там, если что, ссылка на репозиторий Flutter оригинала. Можете лучше — сделайте, покажите…
ookami_kb
А, прошу прощения, не обратил внимания, что флаттеровский код – от автора редизайна. Да, тогда его проект – это прям анти-реклама флаттера какая-то.
KivyMD Автор
У нас был, кстати, подобный челлендж: мы с ребятами делали одно и тоже приложение на Kivy, ReactNative и Xamarin — habr.com/ru/post/420691 Многие спрашивали, почему нет Flutter. Можно сделать что-то подобное снова, если есть желание…
ookami_kb
Да, можно. Я за.
KivyMD Автор
Нужно только подходящее приложение найти. Не слишком большое, чтобы обзор кода, проблем — а у меня по-любому они возникнут :D — и т.п. уместилось в рамках одной статьи. Но в тоже время и не слишком простое. Чтобы был интерес.
ookami_kb
Мы, кстати, в комментариях к одной из статей собирались сделать showcase-приложения для различных архитектурных подходов на флаттере. Я даже под это дело репозиторий создал. Можем его адаптировать под подходы к мобильным приложениям в целом. Ну или что-нибудь другое придумать.
А если кто-нибудь еще и дизайн приличный нарисует, то вообще замечательно будет.
KivyMD Автор
Ну, подождем, может кто-то предложит хорошую идею по этому поводу…