Приветствую! Сегодня речь снова пойдет о библиотеке KivyMD — наборе виджетов для кроссплатформенной разработки на Python в стиле Material Design. В этой статье я сделаю не обзор виджетов KivyMD, как в недавней статье, а, скорее, это будет материал больше о позиционировании виджетов. Что-то похожего на туториал по разработке мобильных приложений на Python для новичков здесь не будет, так что если впервые слышите о фреймворке Kivy, вряд ли вам будет все это интересно. Ну, а мы погнали под кат!

На днях скачал из Google Play демонстрационное приложение Flutter UIKit:


И сейчас мы с вами попробуем повторить один экран из этого приложения. Давайте сразу посмотрим на результаты: слева — Flutter, справа — Kivy & KivyMD.

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

Итак! Что бросается в глаза, глядя на экран, который мы будем воспроизводить? Прозрачный фон переднего layout. В Kivy такую возможность предоставляет FloatLayout, который позволяет размещать в себе виджеты и контроллы один над другим следующим образом:


Схематично наш экран будет выглядеть так:


Разметка этого экрана довольно простая:


Почему я говорю о FloatLayout, если наш экран унаследован от Screen?

<ProductScreen@Screen>:

    ...

Просто потому, что Screen --> RelativeLayout --> FloatLayout.

Все виджеты во FloatLayout позиционируются от нижнего левого угла, то есть, на экране им автоматически присваивается позиция (0, 0). В разметке не сложно проследить порядок добавления элементов на экран сверху вниз:


Если кто-то обратил внимание, то позицию мы указали только одному виджету:

MDToolbar:
    ...
    pos_hint: {"top": 1}

Каждому виджету в Kivy помимо конкретных координат (x, y) можно указать подсказку позиции:

pos_hint: {"top": 1}  # верхняя граница экрана
pos_hint: {"bottom": 1}  # нижняя граница экрана
pos_hint: {"right": 1}  # правая граница экрана
pos_hint: {"center_y": .5}  # центр экрана по вертикали
pos_hint: {"center_x": .2}  # отступ в 20 % по горизонтали от левой границы экрана
...
...

Так вот, нижнее фоновое изображение…

    BoxLayout:
        size_hint_y: None
        height: root.height - toolbar.height

        FitImage:
            source: "smokestackheather.jpeg"

… благодаря виджету FitImage (библиотека KivyMD), автоматически растягивается на все выделенное ему пространство с сохранением пропорций изображения:



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

from kivy.app import App
from kivy.lang import Builder

KV = """
Button:
    text: "Button"
"""


class MyApp(App):
    def build(self):
        return Builder.load_string(KV)


MyApp().run()

И получите результат:


Кнопка заняла 100 % пространства. Чтобы разместить кнопку по центру экрана, нужно, во-первых, задать ей необходимый размер и, во-вторых, указать, где она будет находится:

from kivy.app import App
from kivy.lang import Builder

KV = """
Button:
    text: "Button"
    size_hint: None, None
    size: 100, 50
    pos_hint: {"center_y": .5, "center_x": .5}
"""


class MyApp(App):
    def build(self):
        return Builder.load_string(KV)


MyApp().run()

Теперь картина изменилась:


Также можно указать свойство size_hint, от 0 до 1, (эквивалент 0-100%), то есть, подсказка размера:

from kivy.app import App
from kivy.lang import Builder

KV = """
BoxLayout:

    Button:
        text: "Button"
        size_hint_y: .2

    Button:
        text: "Button"
        size_hint_y: .1

    Button:
        text: "Button"
"""


class MyApp(App):
    def build(self):
        return Builder.load_string(KV)


MyApp().run()


Или тоже самое, но подсказка ширины (size_hint_x):

from kivy.app import App
from kivy.lang import Builder

KV = """
BoxLayout:

    Button:
        text: "Button"
        size_hint_x: .2

    Button:
        text: "Button"
        size_hint_x: .1

    Button:
        text: "Button"
"""


class MyApp(App):
    def build(self):
        return Builder.load_string(KV)


MyApp().run()


MDToolbar имеет высоту в 56dp, не может занимать все пространство, и если ему не подсказать, что его место сверху, то он автоматически прилипнет к нижней части экрана:


Список карточек — OrderProductLayout (о нем мы поговорим ниже) — это ScrollView с элементами MDCard и он занимает всю высоту экрана, но благодаря padding (значения отступов в лайоутах) кажется, что он находится чуть выше центра экрана. Ну а MDBottomAppBar по умолчанию кидает якорь к нижней границе экрана. Поэтому только MDToolbar мы указали, где его место.

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


Как видим, это четыре карточки, вложенные в ScrillView. В отличие от родительского экрана, который унаследован от FloatLayout, здесь все виджеты читаются сверху вниз.


Это очень удобно, поскольку прослеживается четкая иерархия виджетов, древовидная структура и с одного взгляда понятно, какой виджет/контролл какому лайоуту принадлежит. В Kivy наиболее частым используемым лайоутом является BoxLayout — коробка, которая позволяет размещать в себе виджеты по вертикали либо по горизонтали (по умолчанию — последнее):


Более наглядно это видно из следующей схемы, где используется BoxLayout горизонтальной ориентации:


Мы запретили BoxLayout использовать 100% пространства — size_hint_y: None и сказали — твоя высота будет ровно такой, какой будет высота самого высокого элемента, вложенного в тебя — height: self.minimum_height.

Список изображений:


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

    ScrollView:

        GridLayout:
            size_hint_y: None
            height: self.minimum_height
            cols: 1

Заменить строки (rows) на столбцы (cols) и указать в minimum не ширину, а высоту:

from kivy.app import App
from kivy.lang import Builder
from kivy.metrics import dp
from kivy.uix.button import Button

KV = """
ScrollView:

    GridLayout:
        id: box
        size_hint_y: None
        height: self.minimum_height
        spacing: "5dp"
        cols: 1
"""


class MyApp(App):
    def build(self):
        return Builder.load_string(KV)

    def on_start(self):
        for i in range(20):
            self.root.ids.box.add_widget(
                Button(
                    text=f"Label {i}",
                    size_hint_y=None,
                    height=dp(40),
                )
            )


MyApp().run()



Следующие карты — выбор цвета и размера (они практически идентичны):


Отличительной особенностью языка разметки Kv Language является не только четкая структура виджетов, но и то, что этот язык поддерживает некоторые возможности языка Python. А именно: вызов методов, создание/изменение переменных, логические, I/O и математические операции…


Вычисление значения value, объявленного в Label

        Label:
            value: 0
            text: str(self.value)

… происходит непосредственно в самой разметке:

        MDIconButton:
            on_release: label_value.value -= 1 if label_value.value > 0 else 0

И я никогда не поверю, что вот это (код Flutter)…



… логичнее и читабельнее кода Kv Language:


Вчера меня спрашивали, как у Kivy обстоят дела со средой разработки, есть ли автокомплиты, хотрелоад и прочие прелести? С автокомплитами все отлично, если пользоваться PyCharm:


Насчет хотрелоад… Python — интерпретируемый язык. Kivy использует Python. Соответственно, чтобы увидеть результат, не нужна компиляция кода, запустил — увидел/протестирвал. Как я уже говорил, Kivy не использует нативные API для рендера UI, поэтому позволяет эмулировать различные модели устройств и платформ с помощью модуля screen. Достаточно запустить ваш проект с нужными параметрами, чтобы на компьютере открылось окно тестируемого приложения так, как если бы оно было запущено на реальном устройстве. Звучит странно, но поскольку Kivy абстрагируется от платформы в отрисовке UI, это позволяет не использовать тяжелые и медленные эмуляторы для тестов. Это касается только UI. Например, тестовое приложение, описываемое в этой статье тестировалось с параметрами -m screen:droid2, portrait, scale=.75.

Слева — запущено на мобильном устройстве, справа — на компьютере:

Полный список параметров модуля screen:
devices = {
    # device: (name, width, height, dpi, density)
    'onex': ('HTC One X', 1280, 720, 312, 2),
    'one': ('HTC One', 1920, 1080, 468, 3),
    'onesv': ('HTC One SV', 800, 480, 216, 1.5),
    's3': ('Galaxy SIII', 1280, 720, 306, 2),
    'note2': ('Galaxy Note II', 1280, 720, 267, 2),
    'droid2': ('Motorola Droid 2', 854, 480, 240, 1.5),
    'xoom': ('Motorola Xoom', 1280, 800, 149, 1),
    'ipad': ('iPad (1 and 2)', 1024, 768, 132, 1),
    'ipad3': ('iPad 3', 2048, 1536, 264, 2),
    'iphone4': ('iPhone 4', 960, 640, 326, 2),
    'iphone5': ('iPhone 5', 1136, 640, 326, 2),
    'xperiae': ('Xperia E', 480, 320, 166, 1),
    'nexus4': ('Nexus 4', 1280, 768, 320, 2),
    'nexus7': ('Nexus 7 (2012 version)', 1280, 800, 216, 1.325),
    'nexus7.2': ('Nexus 7 (2013 version)', 1920, 1200, 323, 2),

    # taken from design.google.com/devices
    # please consider using another data instead of
    # a dict for autocompletion to work
    # these are all in landscape
    'phone_android_one': ('Android One', 854, 480, 218, 1.5),
    'phone_htc_one_m8': ('HTC One M8', 1920, 1080, 432, 3.0),
    'phone_htc_one_m9': ('HTC One M9', 1920, 1080, 432, 3.0),
    'phone_iphone': ('iPhone', 480, 320, 168, 1.0),
    'phone_iphone_4': ('iPhone 4', 960, 640, 320, 2.0),
    'phone_iphone_5': ('iPhone 5', 1136, 640, 320, 2.0),
    'phone_iphone_6': ('iPhone 6', 1334, 750, 326, 2.0),
    'phone_iphone_6_plus': ('iPhone 6 Plus', 1920, 1080, 400, 3.0),
    'phone_lg_g2': ('LG G2', 1920, 1080, 432, 3.0),
    'phone_lg_g3': ('LG G3', 2560, 1440, 533, 3.0),
    'phone_moto_g': ('Moto G', 1280, 720, 327, 2.0),
    'phone_moto_x': ('Moto X', 1280, 720, 313, 2.0),
    'phone_moto_x_2nd_gen': ('Moto X 2nd Gen', 1920, 1080, 432, 3.0),
    'phone_nexus_4': ('Nexus 4', 1280, 768, 240, 2.0),
    'phone_nexus_5': ('Nexus 5', 1920, 1080, 450, 3.0),
    'phone_nexus_5x': ('Nexus 5X', 1920, 1080, 432, 2.6),
    'phone_nexus_6': ('Nexus 6', 2560, 1440, 496, 3.5),
    'phone_nexus_6p': ('Nexus 6P', 2560, 1440, 514, 3.5),
    'phone_samsung_galaxy_note_4': ('Samsung Galaxy Note 4',
                                    2560, 1440, 514, 3.0),
    'phone_samsung_galaxy_s5': ('Samsung Galaxy S5', 1920, 1080, 372, 3.0),
    'phone_samsung_galaxy_s6': ('Samsung Galaxy S6', 2560, 1440, 576, 4.0),
    'phone_sony_xperia_c4': ('Sony Xperia C4', 1920, 1080, 400, 2.0),
    'phone_sony_xperia_z_ultra': ('Sony Xperia Z Ultra', 1920, 1080, 348, 2.0),
    'phone_sony_xperia_z1_compact': ('Sony Xperia Z1 Compact',
                                     1280, 720, 342, 2.0),
    'phone_sony_xperia_z2z3': ('Sony Xperia Z2/Z3', 1920, 1080, 432, 3.0),
    'phone_sony_xperia_z3_compact': ('Sony Xperia Z3 Compact',
                                     1280, 720, 313, 2.0),
    'tablet_dell_venue_8': ('Dell Venue 8', 2560, 1600, 355, 2.0),
    'tablet_ipad': ('iPad', 1024, 768, 132, 1.0),
    'tablet_ipad_mini': ('iPad Mini', 1024, 768, 163, 1.0),
    'tablet_ipad_mini_retina': ('iPad Mini Retina', 2048, 1536, 326, 2.0),
    'tablet_ipad_pro': ('iPad Pro', 2732, 2048, 265, 2.0),
    'tablet_ipad_retina': ('iPad Retina', 2048, 1536, 264, 2.0),
    'tablet_nexus_10': ('Nexus 10', 2560, 1600, 297, 2.0),
    'tablet_nexus_7_12': ('Nexus 7 12', 1280, 800, 216, 1.3),
    'tablet_nexus_7_13': ('Nexus 7 13', 1920, 1200, 324, 2.0),
    'tablet_nexus_9': ('Nexus 9', 2048, 1536, 288, 2.0),
    'tablet_samsung_galaxy_tab_10': ('Samsung Galaxy Tab 10',
                                     1280, 800, 148, 1.0),
    'tablet_sony_xperia_z3_tablet': ('Sony Xperia Z3 Tablet',
                                     1920, 1200, 282, 2.0),
    'tablet_sony_xperia_z4_tablet': ('Sony Xperia Z4 Tablet',
                                     2560, 1600, 297, 2.0)TodoList()
        app.run()

}


Ну, и, наконец, финальный результат — запуск на мобильном устройстве…


Единственное, что огорчает, это скорость запуска. У того же Flutter она просто феноменальная!

Надеюсь, был кому-то полезен, до новых встреч!

Комментарии (28)


  1. hardtop
    13.12.2019 11:02

    Киви — отличная штука для быстрых стартов. Но вот стоит ли писать на нём что-то серьёзное? Не выкинет ли Apple приложение для iOS, так как оно написано не наитивно?


    1. HeaTTheatR Автор
      13.12.2019 11:32

      Не выкидывает же...


    1. vcambur
      13.12.2019 15:08

      а flutter сильно отличается разве от Kivy в этом плане


  1. Cleveland_boyz
    13.12.2019 12:02

    А Kivy еще поддерживают?


    1. HeaTTheatR Автор
      13.12.2019 12:03

      И очень активно.


  1. undersunich
    13.12.2019 14:32

    Хорошее сравнение.Flutter по сравнению с приведенными аргументами при отображении(создании) виджетов выглядит действительно уныло.Это правда.Но как обстоят дела у Киви с вызовом нативных функцие, как дела с управлением состоянием приложения, как например работать с БД.Что есть? Приведите свои аргументы «за».


  1. HeaTTheatR Автор
    13.12.2019 15:05

    Что вы подразумеваете под "состоянием приложения"?


    Flutter по сравнению с приведенными аргументами при отображении(создании) виджетов выглядит действительно уныло

    Не только выглядит. Вчера обновил официальное демонстрационное приложение на Flutter… Половина примеров UI там безбожно тормозит.


    Как работать с БД?

    Точно так же, как и в Python на десктопе.


    Как обстоят дела у Киви с вызовом нативных функций?

    Нормально дела обстоят. Происходит это следующим образом с помощью библиотеки PyJnius для Андроид и PuObjus для iOS:


    def get_imei_android():
        from jnius import autoclass
    
        Service = autoclass('org.renpy.android.PythonActivity').mActivity
        Context = autoclass('android.content.Context')
        TelephonyManager = Service.getSystemService(Context.TELEPHONY_SERVICE)
        return str(TelephonyManager.getDeviceId())

    Для примера — реализация нативного получения IMEI устройства на Java:


    import android.content.Context;
    import android.telephony.TelephonyManager;
    
    public class GetImeiAndroid {
        public String getImeiAndroid()
        {
            TelephonyManager  tm = (TelephonyManager)getSystemService(Context.TELEPHONY_SERVICE); 
            String IMEINumber = tm.getDeviceId(); 
            return IMEINumber;
        }
    }


    1. undersunich
      13.12.2019 15:20

      Под управлением состоянием приложения я подразумеваю применение «модных» патернов типа «BloC,Flux,Redux etc...».Это повсюду применяется для Flutter приложений.Как тут дела у Киви? В любом случае спасибо за обзор, напишите еще.


      1. HeaTTheatR Автор
        13.12.2019 15:33

        В Kivy совершенно не нужно!


        1. Neikist
          13.12.2019 15:37

          Эммм… Ну ну.


  1. nikita_dol
    13.12.2019 15:32

    На сколько сложно сделать такое на Kyvy?


    1. HeaTTheatR Автор
      13.12.2019 15:42

      В KivyMD для этого существует виджет UserAnimationCard:


      image


      class MainApp(MDApp):
          user_animation_card = ObjectProperty()
      
          def build(self):
              if not self.user_animation_card:
                  self.user_animation_card = MDUserAnimationCard(
                      user_name="User Name",
                      path_to_avatar="path_to_avatar",
                  )
                  self.user_animation_card.box_content.add_widget(ContentClass())
              self.user_animation_card.open()
      
      if __name__ == "__main__":
          MainApp().run()


      1. nikita_dol
        13.12.2019 15:51

        SliverAppBar — даёт больше вариантов применения (Не могу найти документацию по MDUserAnimationCard)


        1. HeaTTheatR Автор
          13.12.2019 15:55

          У нас пока не хватает времени реализовать все возможности, которые хотелось бы — KivyMD wiki


          1. nikita_dol
            13.12.2019 15:57

            Но тут только пример


            1. HeaTTheatR Автор
              13.12.2019 16:14

              Как таковой, документации у нас, к сожалению, нет. По двум причинам. Во-первых, на это нет времени, потому что два человека не могут успеть всё. А во-вторых, обширнейшая документация есть у Kivy. А KivyMD == Kivy. Поэтому если вы знакомы с документацией Kivy, вам будет достаточно посмотреть на примеры из Wiki KivyMD, чтобы начать с ним работать. А вообще, да, документацию мы работаем.


  1. nikita_dol
    13.12.2019 15:54

    Скачал play.google.com/store/apps/details?id=org.kivymd.kivymd
    Клавиатура перекрывает текстовые поля
    Текст вылазит за пределы диалога
    Одинаковая анимация между экранами, которая не характерна для android — тоже нечто


    1. HeaTTheatR Автор
      13.12.2019 16:00

      А вы на год смотрели? На версию KivyMD смотрели? Пакет собран из репозитория на GitLab, который уже четыре года как не поддерживается! Об этом я писал в этой статье Нашей исправленной версии в сторах нет! И потом… Если клавиатура перекрывает текстовое поле, то это не вина библиотеки, а рукожопого программиста, который забыл указать Window.softinput_mode = "below_target". Это касается и анимации, которая устанавливается в ScreenManager и текста, которому не указали shorten!


      image


      image


      1. nikita_dol
        13.12.2019 16:06

        Что на счёт навигации между экранами и характерного для iOS жеста назад?


        1. HeaTTheatR Автор
          13.12.2019 16:20

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


          ['double_tap_time', 'grab_state', 'is_double_tap', 'is_mouse_scrolling', 'is_touch', 'is_triple_tap', 'move', 'push', 'push_attrs', 'push_attrs_stack', 'scale_for_screen', 'time_end', 'time_start', 'time_update', 'triple_tap_time', 'ungrab', 'update_time_end']

          Поэтому не составляет труда поймать ивент свайпа и переключить экран. Но так чтобы конкретно это было реализовано для iOS… Не понятно зачем?..


          1. nikita_dol
            13.12.2019 16:24

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


            1. HeaTTheatR Автор
              13.12.2019 16:29

              Вот переключение слайда свайпом. С таким успехом я просто назначаю в менеджере экранов имя экрана, которое желаю активировать. Это довольно тривиальная задача.


              image


              1. nikita_dol
                13.12.2019 16:31

                Нет. У вас обычный PageView. Я про навигацию между экранами


                1. HeaTTheatR Автор
                  13.12.2019 16:32

                  Есть же ScreenManager…


                  1. nikita_dol
                    13.12.2019 16:38

                    Окееей.
                    Что на счёт hero анимаций между экранами?


                    1. HeaTTheatR Автор
                      13.12.2019 16:49

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


                      image


                      Более того этот вопрос уже неделю закреплен в нашем TODO листе в репозитории KivyMD.


  1. apes_aping_apes
    15.12.2019 22:07

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


    1. HeaTTheatR Автор
      16.12.2019 00:25

      с такими приложениями делать абсолютно нечего

      С какими "такими"?