Kivy — еще проще, еще нативнее

в 9:17, , рубрики: python, python kivy, разработка мобильных приложений, Разработка под android, метки:
Kivy — еще проще, еще нативнее - 1

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

Скачайте, распакуйте KivyMD, зайдите в корневой каталог распакованного архива и выполните установку:

python setup.py install

Далее, установите зависимости для KivyMD:

pip install kivy-garden
garden install recycleview

После установки библиотеки вы можете запустить тестовый пример из скачанного вами и распакованного архива:

python kitchen_sink.py

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

В статье мы не будем останавливаться на каких-то конкретных виджетах библиотеки (их создание и параметры прекрасно описаны в том же самом kitchen_sink.py), а создадим простое демонстрационное приложение «Контакты» с использованием KivyMD. Наше приложение будет уметь создавать контакты и группы, а также добавлять в них созданные контакты. Ну, и попутно более детально осветим некоторые аспекты создания интерфейса приложений в Kivy:

Для простого создания дефолтного проекта на Kivy рекомендую CreatorKivyProject, детальное описание работы с которым описанно в этой статье. Итак, следуя инструкциям в статье по ссылке, проект DemoKivyContacts создан. Откроем файл по пути DemoKivyContacts/libs/uix/kv/startscreen.kv, безжалостно удалим все его содержимое и «нарисуем» стартовый экран своего приложения!

Kivy — еще проще, еще нативнее - 2

Вот так выглядит разметка данного интерфейса в Kivy-Language:

startscreen.kv

#:kivy 1.9.1 
#:import CreateContact libs.uix.createcontact.CreateContact 
#:import CallContact libs.uix.callcontact.CallContact 
#:import EmptyScreen libs.uix.emptyscreen.EmptyScreen 
#:import Toolbar kivymd.toolbar.Toolbar 
#:import MDTabbedPanel kivymd.tabs.MDTabbedPanel 
#:import MDTab kivymd.tabs.MDTab 

############################################################################### 
# 
#                              СТАРТОВЫЙ ЭКРАН 
# 
############################################################################### 
<StartScreen>: 
    id: root.manager 
    Screen: 
        name: 'root_screen' 
        BoxLayout: 
            #canvas: 
            #    Rectangle: 
            #        pos: self.pos 
            #        size: self.size 
            #        source: 'data/images/background.jpg' 
            orientation: 'vertical' 
            #################################################################### 
            # 
            #                            ACTION BAR 
            # 
            #################################################################### 
            Toolbar: 
                #canvas.before: 
                #    Rectangle: 
                #        pos: self.pos 
                #        size: self.size 
                #        source: 'data/images/background_toolbar.jpg' 

                id: action_bar 
                #background_color: app.data.alpha 
                background_color: app.theme_cls.primary_color 
                title: app.data.string_lang_contacts 
                left_action_items: [['menu', lambda x: app.nav_drawer.toggle()]] 
                right_action_items: [['more-vert', lambda x: None]] 
            #################################################################### 
            # 
            #                           TABBED PANEL 
            # 
            #################################################################### 
            MDTabbedPanel: 
                id: tabs 
                tab_display_mode: 'text' 
                #tab_color: app.data.alpha 
                tab_text_color: app.data.tab_text_color 
                tab_indicator_color: app.data.tab_indicator_color 

                MDTab: 
                    name: 'contacts' 
                    text: app.data.string_lang_contacts 
                    on_tab_press: app.on_tab_press(self.name) 
                    ScreenManager: 
                        id: screen_manager_tab_contacts 
                        Screen: 
                            name: 'empty_contacts_list' 
                            EmptyScreen: 
                                image: 'data/images/contacts.png' 
                                text: app.data.string_lang_add_contacts 
                                callback: app.show_form_create_contact 
                                disabled: False 
                        Screen: 
                            name: 'create_contact' 
                            CreateContact: 
                MDTab: 
                    name: 'groups' 
                    text: app.data.string_lang_groups 
                    on_tab_press: app.on_tab_press(self.name) 
                    ScreenManager: 
                        id: screen_manager_tab_groups 
                        Screen: 
                            name: 'empty_groups_list' 
                            EmptyScreen: 
                                image: 'data/images/contacts.png' 
                                text: app.data.string_lang_not_groups 
                                callback: lambda: app.create_group() 
                                disabled: False 
    Screen: 
        name: 'call_contact' 
        CallContact:

Как видите, наш экран использует:

Toolbar

    Toolbar: 
        id: action_bar 
        background_color: app.theme_cls.primary_color 
        title: app.data.string_lang_contacts 
        left_action_items: [['menu', lambda x: app.nav_drawer.toggle()]] 
        right_action_items: [['more-vert', lambda x: None]] 

Kivy — еще проще, еще нативнее - 3

и MDTabbedPanel с вкладками MDTab

    MDTabbedPanel: 
        id: tabs 
        tab_display_mode: 'text' 
        tab_text_color: app.data.tab_text_color 
        tab_indicator_color: app.data.tab_indicator_color 

        MDTab: 
            name: 'contacts' 
            text: app.data.string_lang_contacts 
            on_tab_press: app.on_tab_press(self.name) 
            ScreenManager: 
                id: screen_manager_tab_contacts 
                Screen: 
                    name: 'empty_contacts_list' 
                    EmptyScreen: 
                        image: 'data/images/contacts.png' 
                        text: app.data.string_lang_add_contacts 
                        callback: app.show_form_create_contact 
                        disabled: False 
                Screen: 
                    name: 'create_contact' 
                    CreateContact: 
        MDTab: 
            name: 'groups' 
            text: app.data.string_lang_groups 
            on_tab_press: app.on_tab_press(self.name) 
            ScreenManager: 
                id: screen_manager_tab_groups 
                Screen: 
                    name: 'empty_groups_list' 
                    EmptyScreen: 
                        image: 'data/images/contacts.png' 
                        text: app.data.string_lang_not_groups 
                        callback: lambda: app.create_group

Kivy — еще проще, еще нативнее - 4

Эти виджеты библиотеки KivyMD мы импортировали в самом начале файла разметки startscreen.kv:

#:import Toolbar kivymd.toolbar.Toolbar 
#:import MDTabbedPanel kivymd.tabs.MDTabbedPanel 
#:import MDTab kivymd.tabs.MDTab

Данные инструкции в Kivy-Language аналогичны импорту в python сценариях:

from kivymd.toolbar import Toolbar 
from kivymd.tabs import MDTabbedPanel 
from kivymd.tabs import MDTab

К слову, в kv-файле вы можете включать другие файлы разметки, если интерфейс, например, слишком сложный:

#:include your_kv_file.kv

У нас имеются две вкладки на MDTabbedPanel — «Контакты» и «Группы». Первая («Контакты») будет содержать виджет ScreenManager (менеджер экранов), в котором мы разместим два, говоря языком Java, Activity:

Вкладка «Контакты»

        MDTab: 
            name: 'contacts' 
            text: app.data.string_lang_contacts 
            on_tab_press: app.on_tab_press(self.name) 
            ScreenManager: 
                id: screen_manager_tab_contacts 
                Screen: 
                    name: 'empty_contacts_list' 
                    EmptyScreen: 
                        image: 'data/images/contacts.png' 
                        text: app.data.string_lang_add_contacts 
                        callback: app.show_form_create_contact 
                        disabled: False 
                Screen: 
                    name: 'create_contact' 
                    CreateContact: 

Как вы могли заметить, ScreenManager должен включать один или несколько виджетов Screen (экранов), которые будут содержать наш контент (Activity). В нашем случае это EmptyScreen (пустой экран) и CreateContact (форма создания нового контакта):

Kivy — еще проще, еще нативнее - 5

Переключаться между данными Activity мы будем по их именам:

                Screen: 
                    name: 'empty_contacts_list' 

                    …

                Screen: 
                    name: 'create_contact' 

                    …

… используя объект ScreenManager...

            ScreenManager: 
                id: screen_manager_tab_contacts 

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

Kivy — еще проще, еще нативнее - 6

… и переключая Activity посредством передачи аттрибуту current имени нового экрана:

self.manager_tab_contacts.current = 'create_contact'

Теперь «нарисуем» наши Activity — EmptyScreen (пустой экран) и CreateContact (форму создания нового контакта). Создадим файлы разметки интерфейса в директории проекта DemoKivyContacts/libs/uix/kv emptyscreen.kv и createcontact.kv и одноименные python сценарии в директории DemoKivyContacts/libs/uix для управления и передачи параметров созданным виджетам EmptyScreen и CreateContact:

emptyscreen.kv

#:kivy 1.9.1 
#:import MDLabel kivymd.label.MDLabel 
#:import MDFloatingActionButton kivymd.button.MDFloatingActionButton 

<EmptyScreen>: 
    id: empty_screen 

    Image: 
        source: root.image 
        pos_hint: {'center_x': .5, 'center_y': .6} 
        opacity: .5 

    MDLabel: 
        id: label 
        font_style: 'Headline' 
        theme_text_color: 'Primary' 
        color: app.data.text_color 
        text: root.text 
        halign: 'center' 

    MDFloatingActionButton: 
        id: float_act_btn 
        icon: 'plus' 
        size_hint: None, None 
        size: dp(56), dp(56) 
        opposite_colors: True
        elevation_normal: 8
        pos_hint: {'center_x': .9, 'center_y': .1} 
        background_color: app.data.floating_button_color 
        background_color_down: app.data.floating_button_down_color 
        disabled: root.disabled 
        on_release: root.callback()

createcontact.kv

#:kivy 1.9.1 
#:import SingleLineTextField kivymd.textfields.SingleLineTextField 
#:import MDIconButton kivymd.button.MDIconButton 
#:import MDFlatButton kivymd.button.MDFlatButton 
#:import MDFloatingActionButton kivymd.button.MDFloatingActionButton 

<CreateContact>: 
    orientation: 'vertical' 

    FloatLayout: 
        size_hint: 1, .3 

        Image: 
            id: avatar 
            pos_hint: {'center_y': .5} 
            source: 'data/images/avatar_empty.png' 

        MDFloatingActionButton: 
            icon: 'plus' 
            size_hint: None, None 
            size: dp(56), dp(56) 
            opposite_colors: True 
            elevation_normal: 8 
            pos_hint: {'center_x': .9, 'center_y': .20} 
            background_color: app.data.floating_button_color 
            background_color_down: app.data.floating_button_down_color 
            on_release: app.choice_avatar_contact() 

    BoxLayout: 
        orientation: 'vertical' 
        padding: 5, 5 
        size_hint: 1, .3 

        BoxLayout: 
            MDIconButton: 
                icon: 'account' 
                disabled: True 
            SingleLineTextField: 
                id: name_field 
                hint_text: 'ИФО' 

        BoxLayout: 
            MDIconButton: 
                icon: 'phone' 
                disabled: True 
            SingleLineTextField: 
                id: number_field 
                hint_text: 'Номер' 

        BoxLayout: 
            MDIconButton: 
                icon: 'email' 
                disabled: True 
            SingleLineTextField: 
                id: email_field 
                hint_text: 'E-mail' 

    Widget: 
        size_hint: 1, .3 

    AnchorLayout: 
        anchor_x: 'right' 
        anchor_y: 'bottom' 
        size_hint: 1, None 
        height: dp(40) 
        MDFlatButton: 
            id: button_ok 
            text: 'OK' 
            on_release: app.save_info_contact()

emptyscreen.py

from kivy.uix.floatlayout import FloatLayout 
from kivy.properties import StringProperty, ObjectProperty, BooleanProperty 

class EmptyScreen(FloatLayout): 
    image = StringProperty() 
    text = StringProperty() 
    callback = ObjectProperty() 
    disabled = BooleanProperty()

createcontact.py

from kivy.uix.boxlayout import BoxLayout 

class CreateContact(BoxLayout): 
    pass

В EmptyScreen мы использовали еще один виджет из библиотеки KivyMD — MDFloatingActionButton, который стоит описать. Та самая назойливая муха, которую многим пользователям хочется прихлопнуть:

Kivy — еще проще, еще нативнее - 7

    MDFloatingActionButton: 
        id: float_act_btn 
        icon: 'plus' 
        size_hint: None, None 
        size: dp(56), dp(56) 
        opposite_colors: True  # иконка белого/черного цветов 
        elevation_normal: 8  # длинна тени 
        pos_hint: {'center_x': .9, 'center_y': .1}  # самое нужное место на экране, которое кнопка обязательно закроет
        background_color: app.data.floating_button_color 
        background_color_down: app.data.floating_button_down_color 
        disabled: root.disabled 
        on_release: root.callback()

В CreateContact используются виджеты библиотеки KivyMD:

MDIconButton:

Kivy — еще проще, еще нативнее - 8

MDIconButton — это кнопка с векторной иконкой. Полный набор официальных иконок от Google смотрите по ссылке. Все они используются и доступны в KivyMD.

SingleLineTextField:

Kivy — еще проще, еще нативнее - 9

MDFlatButton:

Kivy — еще проще, еще нативнее - 10

Будет вызывать функцию сохранения введенных пользователем данных:

        MDFlatButton: 
            …

            on_release: app.save_info_contact()

Получать введенную пользователем информацию из полей SingleLineTextField мы будем по уже описанному способу выше — по их id из аттрибута text:

DemoKivyContacts/libs/uix/kv/createcontact.kv

Kivy — еще проще, еще нативнее - 11

DemoKivyContacts/libs/programclass/showformcreatecontact.py

    def show_form_create_contact(self, *args): 
        '''Выводит на экран форму для создания нового контакта.''' 

        self.manager_tab_contacts.current = 'create_contact' 
        # <class 'libs.uix.createcontact.CreateContact'> 
        self._form_create_contact =  
            self.manager_tab_contacts.current_screen.children[0]

        ...

    def save_info_contact(self): 
        '''Сохраняет информацию о новом контакте.''' 

        name_contact = self._form_create_contact.ids.name_field.text 
        number_contact = self._form_create_contact.ids.number_field.text 
        mail_contact = self._form_create_contact.ids.email_field.text

        ...

После сохранения данных программа создает список контактов, если он не создан, или добавляет новый к уже существующему списку и выводит его на экран:

Kivy — еще проще, еще нативнее - 12

show_contacts

    def show_contacts(self, info_contacts): 
        ''' 
        :type info_contacts: dict; 
        :param info_contacts: { 
            'Name contact': ['Number contactnMail contact', 'path/to/avatar'] 
        }; 

        ''' 

        if not self._contacts_items: 
            # Создаем список контактов. 
            self._contacts_list = ContactsList() 
            self._contacts_items = Lists( 
                dict_items=info_contacts, flag='three_list_custom_icon', 
                right_icons=self.data.right_icons, 
                events_callback=self._event_contact_item 
            ) 

            button_add_contact = Builder.template( 
                'ButtonAdd', disabled=False, 
                events_callback=self.show_form_create_contact 
            ) 
            self._contacts_list.add_widget(self._contacts_items) 
            self._contacts_list.add_widget(button_add_contact) 
            self.add_screens( 
                'contact_list', self.manager_tab_contacts, self._contacts_list 
            ) 
        else: 
            # Добавляет контакт к существующему списку
            # и выводит список на экран. 
            self._add_contact_item(info_contacts) 
            self.manager_tab_contacts.current = 'contact_list'

Обратите внимание на функцию add_screens — программное добавление нового Activity и установка его в качестве текущего экрана:

DemoKivyContacts/program.py

    def add_screens(self, name_screen, screen_manager, new_screen): 
        screen = Screen(name=name_screen)  # cоздаем новый экран
        screen.add_widget(new_screen)  #  добавляем Activity в созданный экран
        screen_manager.add_widget(screen)  #  добавляем экран в менеджер экранов
        screen_manager.current = name_screen  #  указываем менеджеру имя Activity, которое должно стать  текущим экраном приложения

Я написал небольшую (пока топорную) обвязку для создания списков MDList — DemoKivyContacts/libs/uix/lists.py

Создать пункт списка с иконкой слева и векторными иконками справа можно достаточно легко, создав экземпляр класса Lists c нужными параметрами.

    def show_contacts(self, info_contacts): 
        ''' 
        :type info_contacts: dict; 
        :param info_contacts: { 
            'Name contact': ['Number contactnMail contact', 'path/to/avatar'] 
        }; 

        ''' 

        …

        self._contacts_items = Lists( 
            dict_items=info_contacts, flag='three_list_custom_icon', 
            right_icons=self.data.right_icons, 
            events_callback=self._event_contact_item 
        )

Kivy — еще проще, еще нативнее - 13

Далее список self._contacts_items кидаете на любой требуемый виджет.

При создании пункта списка мы передали параметру events_callback функцию _event_contact_item для обработки событий путнкта:

    def _event_contact_item(self, *args): 
        '''События пункта списка контактов.''' 

        def end_call(): 
            self.screen.current = 'root_screen' 

        instanse_button = args[0] 
        if type(instanse_button) == RightButton: 
            name_contact, name_event = instanse_button.id.split(', ') 
            if name_event == 'call': 
                self.screen.current = 'call_contact' 
                data_contact = self.info_contacts[name_contact] 
                call_screen = self.screen.current_screen.children[0] 
                call_screen.name_contact = name_contact 
                call_screen.number_contact = data_contact[0].split('n')[0] 
                call_screen.avatar = data_contact[1] 
                call_screen.callback = end_call 
            elif name_event == 'groups': 
                self._show_names_groups(name_contact) 
        else: 
            name_contact, name_event = args

Идентификаторы событий 'call' и 'group' — это имена иконок, которые мы указали в параметре right_icons:

DemoKivyContacts/libs/programdata.py

…

right_icons = ['data/images/call.png', 'data/images/groups.png']

При нажатии на иконку звонка откроется экран имитации исходящего вызова:

    def _event_contact_item(self, *args): 
        def end_call(): 
            self.screen.current = 'root_screen' 

        …

        if name_event == 'call': 
            self.screen.current = 'call_contact' 
            call_screen = self.screen.current_screen.children[0] 

            …

            call_screen.callback = end_call

Kivy — еще проще, еще нативнее - 14

Все виджеты в нем уже описаны, поэтому просто приведу макет разметки данного Activity:

show_contacts

#:kivy 1.9.1 
#:import MDIconButton kivymd.button.MDIconButton 
#:import MDFloatingActionButton kivymd.button.MDFloatingActionButton 
#:import MDLabel kivymd.label.MDLabel 

<CallContact>: 
    id: call_contact 

    Widget: 
        id: title_line 

        canvas: 
            Color: 
                rgba: app.theme_cls.primary_color 
            Rectangle: 
                size: self.size 
                pos: self.pos 

        size_hint_y: None 
        height: root.height * 30 // 100  # 30% от высоты экрана 
        pos: 0, call_contact.height - self.size[1] 

    Widget: 
        canvas: 
            Ellipse: 
                pos: self.pos 
                size: 150, 150 
                source: root.avatar if root.avatar else 'data/logo/kivy-icon-128.png' 
        pos: (call_contact.width // 2) - 75, call_contact.height * 61 // 100 

    BoxLayout: 
        orientation: 'vertical' 
        size_hint: 1, None 
        height: 50
        pos: self.pos[0], call_contact.height * 45 // 100 

        MDLabel: 
            id: name_contact 
            font_style: 'Headline' 
            theme_text_color: 'Primary' 
            color: app.data.text_color 
            text: root.name_contact if root.name_contact else 'Abonent' 
            halign: 'center' 
        MDLabel: 
            id: number_contact 
            font_style: 'Subhead' 
            theme_text_color: 'Primary' 
            color: app.data.text_color 
            text: root.number_contact if root.number_contact else '12345' 
            halign: 'center' 

    BoxLayout: 
        size_hint: None, None 
        height: 60 
        width: volume.width + dialpad.width + account.width + mic.width 
        pos: (call_contact.width // 2) - (self.width // 2), call_contact.height * 18 // 100 

        MDIconButton: 
            id: volume 
            icon: 'volume-mute' 
        MDIconButton: 
            id: dialpad 
            icon: 'dialpad' 
        MDIconButton: 
            id: account 
            icon: 'account' 
        MDIconButton: 
            id: mic 
            icon: 'mic' 

    MDFloatingActionButton: 
        id: phone_end 
        icon: 'phone-end' 
        size_hint: None, None 
        size: dp(56), dp(56) 
        opposite_colors: True  # иконка белого/черного цветов 
        elevation_normal: 8  # длинна тени 
        pos_hint: {'center_x': .5, 'center_y': .1} 
        background_color: app.data.floating_button_color_end_call 
        background_color_down: app.data.floating_button_down_color_end_call 
        on_release: root.callback()

Виджет CallContact унаследован от FloatLayout:

from kivy.uix.floatlayout import FloatLayout
from kivy.properties import StringProperty, ObjectProperty

class CallContact(FloatLayout):
    callback = ObjectProperty(lambda: None)
    avatar = StringProperty(None)
    name_contact = StringProperty(None)
    number_contact = StringProperty(None)

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

pos: self.pos[0], call_contact.height * 45 // 100 

Теперь, когда вы знаете, как работает ScreenManager, давайте еще раз взглянем на управляющий класс стартового Activity:

from kivy.uix.screenmanager import ScreenManager
from kivy.properties import ObjectProperty

class StartScreen(ScreenManager):
    events_callback = ObjectProperty(lambda: None)
    '''Функция обработки сигналов экрана.'''

и скелет разметки:

<StartScreen>:
    Screen:
        name: 'root_screen'
        …
        # Экран с вкладками — MDTabbedPanel

    Screen:
        name: 'call_contact'
        CallContact:

То есть, при нажатии кнопки вызова в пункте списка контактов мы открываем Activity имитации исходящего вызова и закрываем его при нажатии кнопки «Отбой»:

    def _event_contact_item(self, *args): 
        def end_call(): 
            self.screen.current = 'root_screen' 

        …

        if name_event == 'call': 
            self.screen.current = 'call_contact' 
            call_screen = self.screen.current_screen.children[0] 

            …

            call_screen.callback = end_call

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

Kivy — еще проще, еще нативнее - 15

Для использования панели NavigationDrawer мы должны создать ее разметку и управляющий класс, унаследованный от NavigationDrawer:

nawdrawer.kv

#:kivy 1.9.1

<NavDrawer>:
    NavigationDrawerIconButton:
        icon: 'settings'
        text: app.data.string_lang_settings
        on_release: app.events_program(self.text)
    NavigationDrawerIconButton:
        icon: 'view-module'
        text: app.data.string_lang_plugin
        on_release: app.events_program(self.text)
    NavigationDrawerIconButton:
        icon: 'info'
        text: app.data.string_lang_license
        on_release: app.events_program(self.text)
    NavigationDrawerIconButton:
        icon: 'collection-text'
        text: 'About'
        on_release: app.events_program(self.text)
    NavigationDrawerIconButton:
        icon: 'close-circle'
        text: app.data.string_lang_exit_key
        on_release: app.events_program(app.data.string_lang_exit_key)

DemoKivyContacts/program.py

from kivy.app import App
from kivy.properties import ObjectProperty

from kivymd.navigationdrawer import NavigationDrawer

class NavDrawer(NavigationDrawer):
    events_callback = ObjectProperty()

class Program(App):
    nav_drawer = ObjectProperty()

    def __init__(self, **kvargs):
        super(Program, self).__init__(**kvargs)

    def build(self):
        self.nav_drawer = NavDrawer(title=data.string_lang_menu)

На этом пока все. С полным сценарием проекта вы можете ознакомиться на github.

P.S

Без сомнения библиотека KivyMD является отличным дополнением к фреймворку Kivy! Надеюсь, вы ее освоите и будете применять в своих проектах.

У меня есть предложение поменять формат статей о разработке мобильных приложений с помощью Kivy: возьмем уже готовое нативное приложение для Android, написанное на Java и создадим аналогичное, но написанное на Python с использованием фреймворка Kivy, по ходу действия освещая весь процесс разработки с нуля: как создаются виджеты и контроллы в Kivy, как использовать динамические классы, что есть FloatLayout и т.д.

Автор: HeaTTheatR

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js