Kivy. От создания до production один шаг. Часть 1

  • Tutorial

Буквально статью тому назад, большинством голосов, было решено начать серию уроков по созданию аналога нативного приложения, написанного для Android на Java, но с помощью фреймворка Kivy + Python. Будет рассмотрено: создание и компоновка контроллов и виджетов, углубленное исследование техники разметки пользовательского интерфейса в Kv-Language, динамическое управление элементами экранов, библиотека, предоставляющая доступ к Android Material Design, и многое другое...


Заинтересовавшихся, прошу под кат!


Итак, после безуспешных поисков подопытного кролика подходящего приложения, в меру сложного (чтобы не растягивать наш туториал до масштабов Санты Барбары) и не слишком простого (дабы осветить как можно больше технических аспектов Kivy разработки), по совету хабровчанина Roman Hvashchevsky, который согласился выступить Java консультантом наших уроков (иногда в статьях я буду приводить листинги кода оригинала, написанного на Java), я был переадресован вот сюда — и выбор был сделан:



Conversations — приложение для обмена мгновенными сообщениями для Android, используещее XMPP/Jabber протокол. Альтернатива таким программам, как WhatsApp, WeChat, Line, Facebook Messenger, Google Hangouts и Threema.

Именно на основе данного приложения будут построены наши уроки, а ближе к релизу к концу финальной статьи у нас будет свой пресмыкающийся земноводно-фруктовый тондем питона, жабы и фрукта Jabber-Python-Kivy — PyConversations и заветная apk-шечка, собранная с Python3!


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


python3 main.py PyConversations путь/к/месту/расположения/создаваемого/проекта -repo https://github.com/User/PyConversations -autor Easy -mail gorodage@gmail.com

Естественно, сам фреймворк Kivy, об установке которого можно прочитать здесь. Ну, а замечательную библиотеку KivyMD для создания нативного интерфейса в стиле Android Material Design вы, конечно же, уже нашли по ссылке в репозитории Мастера создания нового проекта.


Теперь отправляйтесь на PornHub github и форкните/ клонируйте/скачайте репу PyConversations, потому что проект, который мы с вами затеяли, будет не маленький, и по ходу выхода новых статей, он будет обрастать новыми функциями, классами и файлами. В противном случае, уже во второй статье вы будете курить бамбук недоумевать, почему у вас ничего не работает.


Итак, проект создан:



Для сегодняшней статьи я взял первые четыре Activity официального приложения Conversations (Activity регистарции нового аккаунта), которые мы с вами сейчас будем создавать:



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


Создание и управление динамическими классами


Базовое представление динамического класса на простом примере:
from kivy.app import App 
from kivy.uix.boxlayout import BoxLayout 
from kivy.lang import Builder 
from kivy.properties import StringProperty 

Builder.load_string(''' 
#: import MDFlatButton kivymd.button.MDFlatButton 

# Данные инструкции в Kivy-Language аналогичны импорту в python сценариях: 
# from kivymd.button import MDFlatButton 
# 
# В kv-файле вы можете включать другие файлы разметки, 
# если интерфейс, например, слишком сложный: #: include your_kv_file.kv 
#
# Стандартные виджеты и контроллы, предоставляемые Kivy из коробки,
# не нужно импортировать в Activity — просто используйте их.

# Все элементы данного Activity будут располагаться в BoxLayout - 
# виджете, от которого унаследован базовый класс. 
<StartScreen> 

    MDFlatButton: 
        id: button 
        text: 'Press Me' 
        size_hint_x: 1  # относительная ширина контролла - от 0 до 1 
        pos_hint: {'y': .5}  # положение контролла относительно вертикали 'y' корневого виджета 

        # Событие контролла. 
        on_release: 
            # Ключевое слово 'root' - это инстанс базового класса разметки, 
            # через который вы можете получить доступ ко всем его методам и атрибутам. 
            root.set_text_on_button() 
''') 
# Или Builder.load_file('path/to/kv-file'), 
# если разметка Activity находится в файле. 

class StartScreen(BoxLayout): 
    '''Базовый класс.''' 

    new_text_for_button = StringProperty() 
    # В Kivy вы должны явно указывать тип атрибутов: 
    # 
    # StringProperty; 
    # NumericProperty; 
    # BoundedNumericProperty; 
    # ObjectProperty; 
    # DictProperty; 
    # ListProperty; 
    # OptionProperty; 
    # AliasProperty; 
    # BooleanProperty; 
    # ReferenceListProperty; 
    # 
    # в противном случае вы получите ошибку 
    # при установке значений этих атрибутов. 
    # 
    # Например, если не указывать тип: 
    # 
    # new_text_for_button = '' 
    # 
    # будет возбуждено исключение - 
    # TypeError: object.__init__() takes no parameters. 

    def set_text_on_button(self): 
        self.ids.button.text = self.new_text_for_button 
        # ids - это словарь всех объектов Activity 
        # которым назначен идентификатор. 
        # 
        # Так, обратившись через идентификатор 'button' - self.ids.button - 
        # к объекту кнопки, мы получаем доступ 
        # ко всем его методам и атрибутам. 

    # Любой атрибут, инициализировванный как Properties, 
    # автоматически получает метод в базовом классе с префиксом 'on_', 
    # который будет вызван как только данный атрибут получит новое значение. 
    def on_new_text_for_button(self, instance, value): 
        print(instance, value) 

class Program(App): 
    def build(self): 
        '''Метод, вызываемый при старте программы. 
        Должен возвращать объект создаваемого Activity.''' 

        return StartScreen(new_text_for_button='This new text') 

if __name__ in ('__main__', '__android__'): 
    Program().run()  # запуск приложения

Ссылаемся на собственные атрибуты и методы внутри Activity:
from kivy.app import App 
from kivy.uix.boxlayout import BoxLayout 
from kivy.lang import Builder 
from kivy.properties import StringProperty 

Builder.load_string(''' 
#: import MDFlatButton kivymd.button.MDFlatButton 

<StartScreen> 

    MDFlatButton: 
        id: button 
        text: 'Press Me' 
        size_hint_x: 1 
        pos_hint: {'y': .5} 

        on_release: 
            # Через ключево слово 'self' мы можем ссылаться 
            # на собственые атрибуты и методы текущего виджета. 
            self.text = root.new_text_for_button 
''') 

class StartScreen(BoxLayout): 
    new_text_for_button = StringProperty() 

    def on_new_text_for_button(self, instance, value): 
        print(instance, value) 

class Program(App): 
    def build(self): 
        return StartScreen(new_text_for_button='This new text') 

if __name__ in ('__main__', '__android__'): 
    Program().run()

Использование id контроллов и виджетов внутри Activity:
from kivy.app import App 
from kivy.uix.boxlayout import BoxLayout 
from kivy.lang import Builder 
from kivy.properties import StringProperty 

Builder.load_string(''' 
#: import MDFlatButton kivymd.button.MDFlatButton 

<StartScreen> 
    orientation: 'vertical' 

    MDFlatButton: 
        id: button 
        text: 'Press Me' 
        size_hint: 1, 1 
        pos_hint: {'center_y': .5} 

        on_release: 
            # Получаем доступ через id к атрибутам и методам второй кнопки. 
            # Обратите внимание, что внутри разметки мы можем выполнять код Python 
            # точно так, как и в обычном Python сценарии. 
            button_two.text = 'Id: "button_two: " {}'.format(root.new_text_for_button) 

    MDFlatButton: 
        id: button_two 
        text: 'Id: "button_two: " Old text' 
        size_hint: 1, 1 
        pos_hint: {'center_y': .5} 
''') 

class StartScreen(BoxLayout): 
    new_text_for_button = StringProperty() 

class Program(App): 
    def build(self): 
        return StartScreen(new_text_for_button='This new text') 

if __name__ in ('__main__', '__android__'): 
    Program().run()

Использование методов с префиксом 'on_' внутри Activity:
from kivy.app import App 
from kivy.uix.boxlayout import BoxLayout 
from kivy.lang import Builder 
from kivy.properties import StringProperty 

Builder.load_string(''' 
#: import MDFlatButton kivymd.button.MDFlatButton 
#: import snackbar kivymd.snackbar 

<StartScreen> 
    orientation: 'vertical' 

    MDFlatButton: 
        id: button 
        text: 'Press Me' 
        size_hint: 1, 1 
        pos_hint: {'center_y': .5} 

        on_release: 
            button_two.text = 'Id: "button_two: " {}'.format(root.new_text_for_button) 

    MDFlatButton: 
        id: button_two 
        text: 'Id: "button_two: " Old text' 
        size_hint: 1, 1 
        pos_hint: {'center_y': .5} 

        on_text: 
            # Событие на изменения значения атрибута 'text'. 
            snackbar.make('О, Боже! Мой текст только что изменили!') 
''') 

class StartScreen(BoxLayout): 
    new_text_for_button = StringProperty() 

class Program(App): 
    def build(self): 
        return StartScreen(new_text_for_button='This new text') 

if __name__ in ('__main__', '__android__'): 
    Program().run()

Использование аттрибутов и методов из главного класса приложения внутри Activity:
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.lang import Builder
from kivy.properties import StringProperty

Builder.load_string('''
#: import MDFlatButton kivymd.button.MDFlatButton

<StartScreen>
    MDFlatButton:
        # Через лкючевое слово 'app' — экземпляр приложения -
        # получаем доступ к методам и атрибутам,
        # инициальзированным в главном классе приложения,
        # унаследованном от kivy.app.App.
        text: app.string_attribute
        size_hint_x: 1
        pos_hint: {'y': .5}
''')

class StartScreen(BoxLayout):
    pass

class Program(App):
    string_attribute = StringProperty('String from App')

    def build(self):
        return StartScreen()

if __name__ in ('__main__', '__android__'):
    Program().run()

Использование Activity без корневого класса:
from kivy.app import App 
from kivy.lang import Builder 

Activity = ''' 
<MyScreen@FloatLayout>: 

    Label: 
        text: 'Text 1' 

BoxLayout: 
    MyScreen: 
''' 

class Program(App): 
    def build(self): 
        return Builder.load_string(Activity) 

if __name__ in ('__main__', '__android__'): 
    Program().run()

Использование ids в Activity без корневого класса:
from kivy.app import App 
from kivy.lang import Builder 

Activity = ''' 
#: import MDFlatButton kivymd.button.MDFlatButton 

# Обратите внимание, если мы не используем базовый класс,
# мы должны указать, базовый виджет. В текущем примере - FloatLayout.
<MyScreen@FloatLayout>: 
    Label: 
        id: label_1 
        text: 'Text 1' 

BoxLayout: 
    orientation: 'vertical' 

    MyScreen: 
        id: my_screen 

    MDFlatButton: 
        text: 'Press me' 
        size_hint_x: 1 

        on_press: 
            my_screen.ids.label_1.text = 'New text' 
''' 

class Program(App): 
    def build(self): 
        return Builder.load_string(Activity) 

if __name__ in ('__main__', '__android__'): 
    Program().run()

Для понимания того, о чем я буду рассказывать далее, этого пока достаточно, остальное буду объяснять в окопе по дороге. Что ж, давайте начнем со стартового Activity нашего проекта. Откройте файл start_screen.kv. В дереве проекта он, как все остальные Activity приложения, размещается в директории libs/uix/kv/activity:



И Activity выглядит так:


#: kivy 1.9.1
#: import Toolbar kivymd.toolbar.Toolbar
#: import NoTransition kivy.uix.screenmanager.NoTransition

<StartScreen>:
    orientation: 'vertical'

    Toolbar:
        id: action_bar
        background_color: app.theme_cls.primary_color  # цвет установленной темы
        title: app.title
        opposite_colors: True  # черная либо белая иконка
        elevation: 10  # длинна тени
        # Иконки слева - 
        # left_action_items: [['name-icon', function], …]
        # Иконки справа - 
        # right_action_items: [['name-icon', function], …]

    ScreenManager:
        id: root_manager
        transition: NoTransition() # эффект смены Activity

        Introduction:
            id: introduction
            # Вызывается при выводе текущего Activity на экран.
            on_enter: self._on_enter(action_bar, app)

        CreateAccount:
            id: create_account
            on_enter: self._on_enter(action_bar, app, root_manager)

        AddAccount:
            id: add_account
            on_enter: self._on_enter(action_bar, app)
            # Вызывается при закрытии текущего Activity.
            on_leave: action_bar.title = app.data.string_lang_create_account

        AddAccountOwn:
            id: add_account_own_provider
            on_enter: self._on_enter(action_bar, app, root_manager)
            on_leave: action_bar.title = app.title; action_bar.left_action_items = []

А вот более наглядно:



Теперь откроем базовый класс Activity StartScreen, который находится по пути libs/uix/kv/activity/baseclass:



startscreen.py:


from kivy.uix.boxlayout import BoxLayout

class StartScreen(BoxLayout):
    pass

Как видите, класс пуст, но унаследован от контейнера BoxLayout, который размещает в себе виджеты вертикально, либо горизонтально в зависимости от параметра 'orientation' — 'vertical' или 'horizontal' (по умолчанию — 'horizontal'). Вот еще более подробная схема Activity StartScreen:



Базовый класс Activity StartScreen, мы унаследовали от BoxLayout, в самой разметке объявили его ориентацию как нетрадиционную вертикальную, и поместили в его контейнер ToolBar и менеджер экранов ScreenManager. ScreenManager — это тоже своего рода контейнер, в который мы помещаем экраны Screen с созданными Activity и в дальнейшем устанавливаем их на экран просто нызывая их по именам. Например:


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

Activity = ''' 
#: import MDFlatButton kivymd.button.MDFlatButton 

ScreenManager: 

    Screen: 
        name: 'Screen one'  # имя экрана

        MDFlatButton: 
            text: 'I`m Screen one with Button' 
            size_hint: 1, 1 
            on_release: 
                root.current = 'Screen two'  # смена экрана

    Screen: 
        name: 'Screen two' 

        BoxLayout: 
            orientation: 'vertical' 

            Image: 
                source: 'data/logo/kivy-icon-128.png' 

            MDFlatButton: 
                text: 'I`m Screen two with Button' 
                size_hint: 1, 1 
                on_release: root.current = 'Screen one' 
''' 

class Program(App): 
    def build(self): 
        return Builder.load_string(Activity) 

if __name__ in ('__main__', '__android__'): 
    Program().run()

Наш ScreenManager содержит четыре экрана с Activity: Introduction, CreateAccount, AddAccount и AddAccountOwn. Начнем с первого:



Introduction.kv
#: kivy 1.9.1 
#: import MDFlatButton kivymd.button.MDFlatButton 

# Стартовое Activity приложения. 

<Introduction>: 
    name: 'Start screen' 

    BoxLayout: 
        orientation: 'vertical' 
        padding: dp(5), dp(20) 

        Image: 
            source: 'data/images/logo.png' 
            size_hint: None, None 
            size: dp(150), dp(150) 
            pos_hint: {'center_x': .5} 

        Label: 
            text: app.data.string_lang_introduction 
            markup: True 
            color: app.data.text_color 
            text_size: dp(self.size[0] - 10), self.size[1] 
            size_hint_y: None 
            valign: 'top' 
            height: dp(250) 

        Widget: 

        BoxLayout: 

            MDFlatButton: 
                text: app.data.string_lang_create_account 
                on_release: app.screen_root_manager.current = 'Create account' 

            MDFlatButton: 
                text: app.data.string_lang_own_provider 
                theme_text_color: 'Primary' 
                on_release: 
                    app.delete_textfield_and_set_check_in_addaccountroot
()
                    app.screen_root_manager.current = 'Add account own provider'

Вот, что представляет данное Activity на экране устройства (я позволил себе некоторые вольности, но, мне показалось, так будет лучше):



Вот оригинал на Java:



Оригинальная разметка Activity в Java
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fillViewport="true">
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="?attr/color_background_primary">

        <LinearLayout
            android:id="@+id/linearLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:minHeight="256dp"
            android:orientation="vertical"
            android:paddingBottom="10dp"
            android:paddingLeft="16dp"
            android:paddingRight="16dp">
            <Space
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"/>
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/welcome_header"
                android:textColor="?attr/color_text_primary"
                android:textSize="?attr/TextSizeHeadline"
                android:textStyle="bold"/>
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:text="@string/welcome_text"
                android:textColor="?attr/color_text_primary"
                android:textSize="?attr/TextSizeBody"/>
            <Button
                android:id="@+id/create_account"
                style="?android:attr/borderlessButtonStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="right"
                android:text="@string/create_account"
                android:textColor="@color/accent"/>
            <Button
                android:id="@+id/use_own_provider"
                style="?android:attr/borderlessButtonStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="right"
                android:text="@string/use_own_provider"
                android:textColor="?attr/color_text_secondary"/>
        </LinearLayout>
        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_above="@+id/linearLayout"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:layout_alignParentTop="true">
            <ImageView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerHorizontal="true"
                android:layout_centerVertical="true"
                android:padding="8dp"
                android:src="@drawable/main_logo"/>
        </RelativeLayout>
        <TextView
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:textColor="?attr/color_text_secondary"
            android:textSize="@dimen/fineprint_size"
            android:maxLines="1"
            android:text="@string/free_for_six_month"
            android:layout_centerHorizontal="true"/>
    </RelativeLayout>
</ScrollView>

Ниже приводится схема Activity Introduction:



Теперь хотелось бы пройти по атрибутам виджетов:


BoxLayout:
    …

    padding: dp(5), dp(20)  # отступы контента от краев контейнера — слева/справа и сверху/снизу

Image:
    …

    # Как следует из имени параметра,это подсказка - относительный
    # размер виджета от 0 до 1 (.1, .5, .01 и т. д.). Если мы желаем
    # указать конкретные размеры, мы должны задать в size_hint
    # значения в None, после чего указать фиксированый размер.
    # Например, укажем ширину изображения:
    #
    # size_hint_x: None
    # width: 250
    #
    # или высоту
    #
    # size_hint_y: None
    # height: 50
    #
    # или, как в коде Activity, и ширину и высоту сразу.
    # По умолчанию параметр size_hint имеет значения (1, 1),
    # то есть, занимает всю доступную ему в контейнере площадь.
    size_hint: None, None
    size: dp(150), dp(150)
    # Относительное положение виджета от ценра по оси 'x'
    # Также есть 'жестское' положение, которое задается в параметре
    # pos, например, pos: 120, 90.
    pos_hint: {'center_x': .5}

С относительными положениями и размерами виджета можете поэкспериментировать на примере ниже:


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

Activity = '''
FloatLayout:

    Button:
        text: "We Will"
        pos: 100, 100
        size_hint: .2, .4

    Button:
        text: "Wee Wiill"
        pos: 280, 200
        size_hint: .4, .2

    Button:
        text: "ROCK YOU!!"
        pos_hint: {'x': .3, 'y': .6}
        size_hint: .5, .2
'''

class Program(App):
    def build(self):
        return Builder.load_string(Activity)

if __name__ in ('__main__', '__android__'):
    Program().run()

Далее по атрибутам:


Label:
    …

    # Указывает, использовать ли markdown теги в тексте
    # или оставить as is.
    # Поддерживаемых тегов немного:
    # [b][/b]
    # [i][/i]
    # [u][/u]
    # [s][/s]
    # [font=<str>][/font]
    # [size=<integer>][/size]
    # [color=#<color>][/color]
    # [ref=<str>][/ref]
    # [anchor=<str>]
    # [sub][/sub]
    # [sup][/sup]
    markup: True
    # Область, ограничивающая текст.
    text_size: dp(self.size[0] - 10), self.size[1]
    # Вертикальное выравнивание текста:
    # 'bottom', 'middle', 'center' или 'top'.
    valign: 'top'

С областью, ограничивающую текст, можете поэкспериментировать на примере ниже:


from kivy.app import App 
from kivy.uix.label import Label 

class LabelTextSizeTest(App): 
    def build(self): 
        return Label( 
            text='Область текста, ограниченная прямоугольником\n' * 50, 
            text_size=(250, 300),  # поэксперементируйте с этими значениями 
            line_height=1.5 
        ) 

if __name__ == '__main__': 
    LabelTextSizeTest().run()

Далее по Activity:


Widget:

В контексте используется как аналог в Java:


<Space
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1"/>

Далее:


BoxLayout:

    MDFlatButton:
        text: app.data.string_lang_create_account
        # Установка Activity с именем 'Create account'.
        on_release: app.screen_root_manager.current = 'Create account'

    MDFlatButton:
        text: app.data.string_lang_own_provider
        # Для установки своего цывета текста на кнопке
        # дайте параметру theme_text_color значение 'Custom'
        # и далее указывайте цвет - text_color: .7, .2, .2, 1
        theme_text_color: 'Primary'
        on_release:
            # Вызов функции из основного класа программы.
            # Можно было реализовать прямо здесь, но, коскольку
            # я считаю, что лишний код в разметке отвлекает
            # от понимания дерева Activity, было решено его вынести.
            app.delete_textfield_and_set_check_in_addaccountroot()
            app.screen_root_manager.current = 'Add account own provider'

Так. У нас остался не рассмотренным еще один вопрос. Вернемся к разметке Activity StartScreen:


        Introduction:
            id: introduction
            # Вызывается при выводе текущего Activity на экран.
            on_enter: self._on_enter(action_bar, app)

То есть, как только Activity будет выведено на экран, выполнится код события on_enter. Давайте посмотрим, что делает метод _on_enter в базовом классе Activity (файл libs/uix/kv/activity/baseclass/introduction.py):



from kivy.uix.screenmanager import Screen

class Introduction(Screen):
    def _on_enter(self, instance_toolbar, instance_program):
        instance_toolbar.left_action_items = []
        instance_toolbar.title = instance_program.title

Метод _on_enter удаляет иконку в ToolBar слева, устанавливая значение left_action_items, как пустой список, и меняет подпись ToolBar на имя приложения.


Для примера приведу управляющий класс из Java оригинала:


WelcomeActivity
package eu.siacs.conversations.ui; 

import android.app.ActionBar; 
import android.app.Activity; 
import android.content.Intent; 
import android.content.pm.ActivityInfo; 
import android.os.Bundle; 
import android.view.View; 
import android.widget.Button; 

import eu.siacs.conversations.R; 

public class WelcomeActivity extends Activity { 

    @Override 
    protected void onCreate(final Bundle savedInstanceState) { 
        if (getResources().getBoolean(R.bool.portrait_only)) { 
            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); 
        } 
        final ActionBar ab = getActionBar(); 
        if (ab != null) { 
            ab.setDisplayShowHomeEnabled(false); 
            ab.setDisplayHomeAsUpEnabled(false); 
        } 
        super.onCreate(savedInstanceState); 
        setContentView(R.layout.welcome); 
        final Button createAccount = (Button) findViewById(R.id.create_account); 
        createAccount.setOnClickListener(new View.OnClickListener() { 
            @Override 
            public void onClick(View v) { 
                Intent intent = new Intent(WelcomeActivity.this, MagicCreateActivity.class); 
                intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); 
                startActivity(intent); 
            } 
        }); 
        final Button useOwnProvider = (Button) findViewById(R.id.use_own_provider); 
        useOwnProvider.setOnClickListener(new View.OnClickListener() { 
            @Override 
            public void onClick(View v) { 
                startActivity(new Intent(WelcomeActivity.this, EditAccountActivity.class)); 
            } 
        }); 

    } 

}

Так. С этим разобрались. У нас есть Activity и две юзабельные кнопки. Начнем с первой:



При клике на кнопку будет выведено Activity CreateAccount:


MDFlatButton:
    text: app.data.string_lang_create_account
    on_release: app.screen_root_manager.current = 'Create account'

Activity CreateAccount (Kivy):



Activity CreateAccount (original):



Откроем Activity CreateAccount нашего проета:



createaccount.kv
#: kivy 1.9.1 
#: import SingleLineTextField kivymd.textfields.SingleLineTextField 
#: import snackbar kivymd.snackbar 

# Activity регистрации нового аккаунта. 
# Вызывается по событию кнопки 'Create account' стартового Activity. 

<CreateAccount>: 
    name: 'Create account' 

    BoxLayout: 
        orientation: 'vertical' 
        padding: dp(5), dp(20) 

        Image: 
            source: 'data/images/logo.png' 
            size_hint: None, None 
            size: dp(150), dp(150) 
            pos_hint: {'center_x': .5} 

        Label: 
            text: app.data.string_lang_enter_user_name 
            markup: True 
            color: app.data.text_color 
            text_size: dp(self.size[0] - 10), self.size[1] 
            size_hint_y: None 
            valign: 'top' 
            height: dp(215) 

        Widget: 
            size_hint_y: None 
            height: dp(10) 

        SingleLineTextField: 
            id: username 
            hint_text: 'Username' 
            message: 'username@conversations.im' 
            message_mode: 'persistent' 
            on_text: app.check_len_login_in_textfield(self) 

        Widget: 

        BoxLayout: 

            MDFlatButton: 
                text: app.data.string_lang_next 
                on_release: 
                    if username.text == '' or username.text.isspace(): \ 
                    snackbar.make(app.data.string_lang_not_valid_username) 
                    else: app.screen_root_manager.current = 'Add account'

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



Заголовок и иконка в ToolBar устанавливаются в базовом классе Activity CreateAccount в методе _on_enter:


from kivy.uix.screenmanager import Screen 

class CreateAccount(Screen): 

    def _on_enter(self, instance_toolbar, instance_program, instance_screenmanager): 
        instance_toolbar.title = instance_program.data.string_lang_create_account 
        instance_toolbar.left_action_items = [ 
            ['chevron-left', lambda x: instance_program.back_screen( 
                instance_screenmanager.previous())] 
        ]

Оригинальный управляющий класс MagicCreateActivity на Java
package eu.siacs.conversations.ui; 

import android.content.Intent; 
import android.content.pm.ActivityInfo; 
import android.os.Bundle; 
import android.text.Editable; 
import android.text.TextWatcher; 
import android.view.View; 
import android.widget.Button; 
import android.widget.EditText; 
import android.widget.TextView; 
import android.widget.Toast; 

import java.security.SecureRandom; 

import eu.siacs.conversations.Config; 
import eu.siacs.conversations.R; 
import eu.siacs.conversations.entities.Account; 
import eu.siacs.conversations.xmpp.jid.InvalidJidException; 
import eu.siacs.conversations.xmpp.jid.Jid; 

public class MagicCreateActivity extends XmppActivity implements TextWatcher { 

    private TextView mFullJidDisplay; 
    private EditText mUsername; 
    private SecureRandom mRandom; 

    private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456780+-/#$!?"; 
    private static final int PW_LENGTH = 10; 

    @Override 
    protected void refreshUiReal() { 

    } 

    @Override 
    void onBackendConnected() { 

    } 

    @Override 
    protected void onCreate(final Bundle savedInstanceState) { 
        if (getResources().getBoolean(R.bool.portrait_only)) { 
            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); 
        } 
        super.onCreate(savedInstanceState); 
        setContentView(R.layout.magic_create); 
        mFullJidDisplay = (TextView) findViewById(R.id.full_jid); 
        mUsername = (EditText) findViewById(R.id.username); 
        mRandom = new SecureRandom(); 
        Button next = (Button) findViewById(R.id.create_account); 
        next.setOnClickListener(new View.OnClickListener() { 
            @Override 
            public void onClick(View v) { 
                String username = mUsername.getText().toString(); 
                if (username.contains("@") || username.length() < 3) { 
                    mUsername.setError(getString(R.string.invalid_username)); 
                    mUsername.requestFocus(); 
                } else { 
                    mUsername.setError(null); 
                    try { 
                        Jid jid = Jid.fromParts(username.toLowerCase(), Config.MAGIC_CREATE_DOMAIN, null); 
                        Account account = xmppConnectionService.findAccountByJid(jid); 
                        if (account == null) { 
                            account = new Account(jid, createPassword()); 
                            account.setOption(Account.OPTION_REGISTER, true); 
                            account.setOption(Account.OPTION_DISABLED, true); 
                            account.setOption(Account.OPTION_MAGIC_CREATE, true); 
                            xmppConnectionService.createAccount(account); 
                        } 
                        Intent intent = new Intent(MagicCreateActivity.this, EditAccountActivity.class); 
                        intent.putExtra("jid", account.getJid().toBareJid().toString()); 
                        intent.putExtra("init", true); 
                        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 
                        Toast.makeText(MagicCreateActivity.this, R.string.secure_password_generated, Toast.LENGTH_SHORT).show(); 
                        startActivity(intent); 
                    } catch (InvalidJidException e) { 
                        mUsername.setError(getString(R.string.invalid_username)); 
                        mUsername.requestFocus(); 
                    } 
                } 
            } 
        }); 
        mUsername.addTextChangedListener(this); 
    } 

    private String createPassword() { 
        StringBuilder builder = new StringBuilder(PW_LENGTH); 
        for(int i = 0; i < PW_LENGTH; ++i) { 
            builder.append(CHARS.charAt(mRandom.nextInt(CHARS.length() - 1))); 
        } 
        return builder.toString(); 
    } 

    @Override 
    public void beforeTextChanged(CharSequence s, int start, int count, int after) { 

    } 

    @Override 
    public void onTextChanged(CharSequence s, int start, int before, int count) { 

    } 

    @Override 
    public void afterTextChanged(Editable s) { 
        if (s.toString().trim().length() > 0) { 
            try { 
                mFullJidDisplay.setVisibility(View.VISIBLE); 
                Jid jid = Jid.fromParts(s.toString().toLowerCase(), Config.MAGIC_CREATE_DOMAIN, null); 
                mFullJidDisplay.setText(getString(R.string.your_full_jid_will_be, jid.toString())); 
            } catch (InvalidJidException e) { 
                mFullJidDisplay.setVisibility(View.INVISIBLE); 
            } 

        } else { 
            mFullJidDisplay.setVisibility(View.INVISIBLE); 
        } 
    } 
}

… вызванном по событию on_enter (когда Activity было выведено на экран):


<StartScreen>: 
    …

    ScreenManager: 
        …

        CreateAccount: 
            on_enter: self._on_enter(action_bar, app, root_manager) 

        …

Также нас интересует событие on_text, когда меняется значение текстового поля:


<CreateAccount>:
    …

        SingleLineTextField: 
            …

            on_text: app.check_len_login_in_textfield(self)

Метод check_len_login_in_textfield из главного класса приложения:



def check_len_login_in_textfield(self, instance_textfield):
    # Если введенное значение в поле больше 20 символов.
    if len(instance_textfield.text) > 20:
            instance_textfield.text = instance_textfield.text[:20]
    # Изменяем значение подписи под текстовым полем согласно
    # введенным пользователем в текстовое поле данным.
    instance_textfield.message = 'username@conversations.im' \
        if instance_textfield.text == '' \
        else '{}@conversations.im'.format(instance_textfield.text)


Итак, если данные текстового поля корректны, выводим Activity AddAccount:


MDFlatButton:
    …

    on_release:
        if …
            …
        else: app.screen_root_manager.current = 'Add account'

В противном случае выводим сообщение о некорректных данных:


MDFlatButton:
    …

    on_release:
        if username.text == '' or username.text.isspace(): \
        snackbar.make(app.data.string_lang_not_valid_username)
        …


Ну, и, наконец, у нас осталось последнее Activity...


Original:



Kivy:



Да, это одно Activity. Из второго, при его выводе на экран, мы просто программно удаляем «лишнее» текстовое поле.


<StartScreen>:
    …

    ScreenManager:
        …

        AddAccount:
            id: add_account
            on_enter: self._on_enter(action_bar, app)
            on_leave: action_bar.title = app.data.string_lang_create_account
        AddAccountOwn:
            id: add_account_own_provider
            on_enter: self._on_enter(action_bar, app, root_manager)
            on_leave: action_bar.title = app.title; action_bar.left_action_items = []


В файлах разметки мы создали шаблоны Activity:


<AddAccount>:
    name: 'Add account'

    AddAccountRoot:
        id: add_account_root

<AddAccountOwn>:
    name: 'Add account own provider'

    AddAccountRoot:
        id: add_account_root

«унаследовав» их от Activity AddAccountRoot:



Activity AddAccountRoot
#: kivy 1.9.1
#: import progress libs.uix.dialogs.dialog_progress
#: import MDFlatButton kivymd.button.MDFlatButton
#: import SingleLineTextField kivymd.textfields.SingleLineTextField
#: import MDCheckbox kivymd.selectioncontrols.MDCheckbox

# Activity регистрации нового аккаунта на сервере.

<AddAccountRoot@BoxLayout>:
    canvas:
        Color:
            rgba: app.data.background
        Rectangle:
            size: self.size
            pos: self.pos

    orientation: 'vertical'
    padding: dp(10), dp(10)

    BoxLayout:
        id: box
        canvas:
            Color:
                rgba: app.data.rectangle
            Rectangle:
                size: self.size
                pos: self.pos
            Color:
                rgba: app.data.list_color
            Rectangle:
                size: self.size[0] - 2, self.size[1] - 2
                pos: self.pos[0] + 1, self.pos[1] + 1

        orientation: 'vertical'
        size_hint_y: None
        padding: dp(10), dp(10)
        spacing: dp(15)
        height: app.window.height // 2

        SingleLineTextField:
            id: username
            hint_text: 'Username'
            on_text:
                if self.message != '': app.check_len_login_in_textfield(self)

        SingleLineTextField:
            id: password
            hint_text: 'Password'
            password: True

        BoxLayout:
            id: box_check
            size_hint_y: None
            height: dp(40)

            MDCheckbox:
                id: check
                size_hint: None, None
                size: dp(40), dp(40)
                active: True
                on_state:
                    if self.active: box.add_widget(confirm_password)
                    else: box.remove_widget(confirm_password)
                    if username.message != '': confirm_password.hint_text = 'Confirm password'

            Label:
                text: 'Register new account on server'
                valign: 'middle'
                color: app.data.text_color
                size_hint_x: .9
                text_size: self.size[0] - 10, self.size[1]

        SingleLineTextField:
            id: confirm_password
            password: True

        Widget:

    Widget:

    BoxLayout:
        padding: dp(0), dp(10)

        MDFlatButton:
            text: app.data.string_lang_cancel
            theme_text_color: 'Primary'
            on_release:
                if app.screen.ids.root_manager.current == 'Add account own provider': \
                app.screen.ids.root_manager.current = 'Start screen'; \
                app.screen.ids.action_bar.title = app.title
                else: \
                app.screen.ids.root_manager.current = 'Create account';
                app.screen.ids.action_bar.title = app.data.string_lang_create_account

        MDFlatButton:
            text: app.data.string_lang_next
            on_release:
                instance_progress, instance_text_wait = \
                progress(text_wait=app.data.string_lang_text_wait.format(app.data.text_color_hex), \
                events_callback=lambda x: instance_progress.dismiss())


Любой виджет в Kivy имеет свойство canvas. Поэтому вы можете рисовать на нем все, что угодно: от примитивных фигур до накладывания текстур. В данном Activity я нарисовал прямоугольник сначала серым цветом, затем сверху наложил прямоугольник белого цвета, но меньшим размером (рисовать просто линии, вычисляя их координаты было лень). Получилась рамка.


При активации чекбокса нижнее текстовое поле удаляется:


MDCheckbox:
    …

    on_state:
        # True/False — активен/не активен
        if self.active: box.add_widget(confirm_password)
        else: box.remove_widget(confirm_password)

        …

Когда Activity AddAccount выводится на экран, устанавливаем значения текстовых полей и их фокус:


from kivy.uix.screenmanager import Screen
from kivy.clock import Clock

class AddAccount(Screen):

    def _on_enter(self, instance_toolbar, instance_program):
        instance_toolbar.title = self.name
        self.ids.add_account_root.ids.username.focus = True
        # Выполняется единожды через заданный интервал времени.
        Clock.schedule_once(instance_program.set_text_on_textfields, .5)

Главный класс программы:


def set_focus_on_textfield(self, interval=0, instance_textfield=None, focus=True):
    if instance_textfield: instance_textfield.focus = focus

def set_text_on_textfields(self, interval):
    add_account_root = self.screen.ids.add_account.ids.add_account_root
    field_username = add_account_root.ids.username
    field_password = add_account_root.ids.password
    field_confirm_password = add_account_root.ids.confirm_password
    field_username.text = self.screen.ids.create_account.ids.username.text.lower()
    field_password.focus = True
    password = self.generate_password()
    field_password.text = password
    field_confirm_password.text = password

    Clock.schedule_once(
        lambda x: self.set_focus_on_textfield(
            instance_textfield=field_password, focus=False), .5
    )
    Clock.schedule_once(
        lambda x: self.set_focus_on_textfield(
            instance_textfield=field_username), .5
    )

Что ж! Четыре запланированных Activity готовы, пальцы устали, голова разболелась. Это я о себе. Поэтому на сегодня пока все. Поскольку невозможно в рамках одной статьи осветить все вопросы, описать все параметры виджетов Kivy и нюансы, они будут рассмотрены в следующих статьях, поэтому не стесняйтесь, задавайте вопросы.


Скорее всего, во второй части статьи будет рассмотрена архитектура самого проекта PyConversations и ваши вопросы относительно первой части, если таковые будут. До встречи!


PyConversations на github.


Поделиться публикацией

Комментарии 20

    0
    А как на счёт iOS?
      0

      уже ответили ниже, но есть один момент:


      Currently, packages for iOS can only be generated with Python 2.7. Python 3.3+ support is on the way.
      0

      Кроссплатформенно. Отличия только в сборке. Я не имею iOS поэтому собрать под него не могу.

        0
        Очень интересная реализация, которая на данный момент, проще java.
          0
          Да, получается проще чем java и как бонус — кроссплатформенность
          Другое дело — это вопрос как гугл будет ранжировать такие приложения. Раньше читал что если гугл определяет что приложение кроссплатформенное (не чистая java) то хуже его ранжирует в выдаче — а это уже проблема с ASO.
          Интересно в дальнейшем рассмотреть работу с пермишенами в приложении и аналог файла AndroidManifest
          0
          спасибо за тренд. я читатель )
            0

            Пожалуйста.

            0
            1 шаг — часть 1-я — том 8-ой, действие 3-е, 4-ре запланированных activity…
            Всё одно и тоже. Примеры конечно хорошие. И то что вы делаете — благородно. Но где чёртова готовая apk-шечка с python3 в 1 шаг? Сколько у вас там этих частей запланировано вообще?
              0

              Я вам уже отписался еще в прошлой статье! Из-за вас одного я не стану перепрыгивать "1 шаг — часть 1-ю, том 8-ой, действие 3-е и 4-ре запланированных activity", только для того, чтобы лично вам показать, как собирается "чёртова готовая apk-шечка с python3". У меня в группе люди BoxLayout от FloatLayout отличить не могут, хотя кому я рассказываю, вы, очевидно, конечно же, обо всем этом уже знаете!

                0
                Вроде было же: https://habrahabr.ru/post/301776/
                шаг, увы не один, конечно (@HeaTTheatR, боли за последнее время не стало меньше?).
                Ну и можно скачать оригинальную kivy-шнягу, которая запускает проекты без apk-шки.
                  0
                  то было про python2 и buildozer. а автор уже 2 поста как предлагает использовать python3. Проблема в том что во первых не очевидно как именно заставить buildozer использовать именно python3 а не python2.
                  2-я проблема: при использовании python3 buildozer запрашивает over 9000 библиотек в придачу, и не всегда сообщая напрямую — какая именно библиотека ему вдруг понадобилась.
                  3-я проблема: тот самый kivyMD который успешно может заработать на ПК (о чудо!) и молча отваливаться на android.

                  ну и последняя проблема (на которой я завис прямо сейчас): в самом buildozer-е при использовании нового python нельзя использовать команду «android deploy» (python-for-android has been rewritten and no
                  longer supports the distribute.sh interface.) а при использовании «android_new deploy» — действительно создаётся apk-шечка. Только она не работает… (java.lang.UnsatisfiedLinkError: Native method not found: org.libsdl.app.SDLActivity.nativeSetEnv:(Ljava/lang/String;Ljava/lang/String;) и весит 20 мб.

                  > Ну и можно скачать оригинальную kivy-шнягу, которая запускает проекты без apk-шки.
                  я хочу сделать простой ежедневник (с функционалом по заказу) для моей сестры. Ей нужно простое приложение чтобы нажал на иконку и оно запустилось. Как вывод: в текущей реализации kivy не подходит даже для создания прототипа, не говоря уже о работоспособном приложении. Фишка в том что автор не понимает — что когда у человека есть цель он сам будет знакомится с API. Благо есть документация (хоть и на английском). https://kivy.org/docs/api-kivy.html.
                  HeaTTheatR выше наприсал: «люди BoxLayout от FloatLayout отличить не могут». — убило. Если люди понимают lamda функции, могут читать одновременно python, kv и java — то отличить BoxLayout от FloatLayout (при том что они хорошо проиллюстрированы красивыми картинками на официальном сайте (с которого вообще стоило бы начать( потому что именно этому и нужно учить людей — пользоваться документацией, а не непонятные выжимки кода которые берутся неизвестно откуда))) — на кого ВООБЩЕ ориентирована эта статья???..
                  И после этого он пишет в заголовке «один шаг». В заголовок вылепливает картинку (с apk ящичком) намекая что у нас таки будет рабочий apk. и это продолжается уже 3 поста с тех пор как он в посте «Простые сладкие приложения с Kivy» написал: «да, я полностью отказался от использования Python2, что и вам советую».

                  p.s. Я конечно много придираюсь и писать интерфейсы на kivy можно и под настольные OS? но тогда как мне кажется автор должен перестать везде и всюду намекать что он пишет мобильное приложение под android.
                    –1

                    Я могу вам посеветовать только одно: пейте почаще чай с сахаром! Сахару, желательно, побольше. И вот когда у вас слипнется в жопе, возможно, оттуда перестанут расти руки!


                    1. Ты пытаешься собрать Бульдозером проект на Kivy с Python3! Флаг тебе в лицо! Это все равно, что строить дом из яичной скорлупы!


                    2. Снова о твоих руках (а возможно, дело в голове): я веду разработку приложения на Kivy для группы ВКонтакте именно с использованием KivyMD, проект тестируется не на одном девайсе. Так что не принебрегай моим советом о чае!

                    Далее твои бредни обиженной истерички даже читать не буду!

                      0
                      ну так подскажите как правильно, я от вас это уже 5 комментариев добиваюсь. Господи да хотя бы просто намекните откуда копать.…… вот так бы сразу взяли и написали что в группе в контакте есть пример как собрать…
                0
                Подскажите пожалуйста как мне победить такую напасть на андроиде: когда я русский текст в textunput -е копирую, а затем туда же вставляю вставляется не текст а кракозыбы.
                Буду очень признателен.
                  0

                  Читайте мой совет тремя постами выше!

                    0
                    Спасибо блин. Чтоб вам только такая документация и попадалась.
                      0

                      Научитесь себя вести по-человечески, потом обращайтесь!

                        0
                        это сейчас я применял слова: пожалуйста, спасибо. Нет правда — скажите что вас задело больше всего? Да я не джентльмен, но и вы не пуп земли. Между прочим вас читаю не только я, и ответы на вопросы могли бы помочь и другим людям. Иначе зачем вообще нам дают возможность писать комментарии? Если не знаете как решить данную багу — так и напишите. Если не справились с созданием apk приложения с python3 — так и напишите. Вас никто не обвинит. Наверняка пока вы с вашими темпами допишите 5-ый пост — уже все баги будут решены. Просто так и напишите.

                        Меня больше волнует ваша «группа» — вы преподаватель? Ух, не завидую я им…
                          0

                          Судя по вашей карме, обилию граматических ошибок, неспособностью понять (выучить/запомнить) элементарные правила, у меня создается двойственное мнение на счет вашего интеллекта. Диалог с вами окончен!

                            0
                            то есть вы на основе своих суждений о моём интеллекте решили что читатели хабра не достойны увидеть ответ на мой вопрос?

                            О каких правилах речь? Орфографических? Умение запоминать абстрактные правила — не есть показатель интеллекта. Я могу назвать первые 80 знаков числа пи, просто потому что моему мозгу сбрендило что это будет интересно. А орфография — не интересно. С такой же лёгкостью я могу их и забыть. И этим невозможно управлять. А если бы было возможно у нас в стране такого понятия как двойка вообще не существовало бы. Все бы просто перепрограммировали бы свои мозги перед каждым новым уроком. Почитайте книги по методикам преподавания — в них во всех пишут одно и тоже: все люди разные. И смысл преподавания в том чтобы именно найти оптимальный способ передачи тех или иных знаний, умений и навыков. (ну и конечно донесение до учащихся о том как не отстрелить себе ногу). А для этого подбирают индивидуальный подход к каждому ученику. Ко мне вот с шестого по девятый класс учительница по русскому языку подход так и не нашла. Моя вина? Конечно моя, но статистика двоек по нашему классу говорит скорее о несостоятельности учителя, чем его учеников.

                            Люди в вашей группе — ваши ученики? О них вы тоже создали свои мнения по их грамматике?
                            кстати «обилию грамматических ошибок» — пишется с двумя м. Мне тоже усомниться в вашем интеллекте? Фигня это всё.

                            О чём я пытаюсь донести: суть не в ошибках — а в возможности донести ответ. И вы явно ею не владеете. Просто признайтесь. Ваше «потом обращайтесь!» — явный намёк от вас: «я знаю но тебе не скажу». Ну так скажите для хабра сообщества. Чтобы они не читали эти мои длинные сообщения. А потом не пытались перечитывать ваши последние три поста. А затем не лезли в гугл за ответом там.

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

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

                Самое читаемое