Буквально статью тому назад, большинством голосов, было решено начать серию уроков по созданию аналога нативного приложения, написанного для 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.


Поделиться с друзьями
-->

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


  1. Snusmumrick97
    02.11.2016 12:52

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


    1. angru
      03.11.2016 15:41

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


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


  1. HeaTTheatR
    02.11.2016 13:15

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


  1. stPhoenix
    02.11.2016 14:06

    Очень интересная реализация, которая на данный момент, проще java.


    1. T0h6BY
      03.11.2016 15:41

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


  1. dzyk
    04.11.2016 17:04

    спасибо за тренд. я читатель )


    1. HeaTTheatR
      05.11.2016 01:06

      Пожалуйста.


  1. Sergey6661313
    05.11.2016 23:40

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


    1. HeaTTheatR
      06.11.2016 01:37

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