Fletを試す(2) - 再利用できるようにコンポーネント化

# はじめに

PythonでFlutterアプリが作れるフレームワーク「Flet」の検証をしました。
FletではControl（画面要素）をコンポーネント化して再利用しやすくできるようだったので、その方法を試してみることにしました。

Fletの公式サイトはこちら。
https://flet.dev/

参考）過去記事はこちら。

https://qiita.com/donraq/items/5182e3f01bd177bb6ff0

# 前提事項

- 「Flet」に関する詳細は省略
- インストール方法、実行方法、デプロイ方法なども省略
- 試した環境
    - Windows10
    - Python 3.10.8
    - Flet 0.3.2

# 概要

- Fletでログインフォームを作り
- それをコンポーネント化してみる
- 併せてイベントの実装方法も確認する

最終形を「Fly.io」にデプロイしたものが以下です。
https://flet-report02.fly.dev/#/
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/02ec07f2-617f-02d4-d3ba-4759d95e8d27.gif)

個人的な感想は以下。
- きちんと作るならコンポーネント化は必須になると思う
- 簡単なツールだったら、main に全部書いていっても良いと思う
- 今後の課題
    - 作った class にプロパティやイベントを持たせる形にできないのか
    - 日本語が最初☒の表示になるのは何とかならないのか

# 詳細

## 基本のソース構成

```python
import flet as ft


class Component(ft.UserControl):
    """sample"""

    def __init__(self):
        super().__init__()

    def build(self):
        return ft.Card(
            ft.Container(
                ft.Column(
                    [
                        ft.Text("sample"),
                        ft.TextField(),
                        ft.ElevatedButton("button"),
                    ],
                    alignment=ft.MainAxisAlignment.CENTER,
                ),
                width=400,
                padding=30,
            ),
        )


def main(page: ft.Page):

    component1 = Component()
    component2 = Component()
    component3 = Component()

    # Page レイアウト
    page.title = "sample"
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
    page.vertical_alignment = ft.MainAxisAlignment.CENTER
    page.add(component1)
    page.add(component2)
    page.add(component3)


if __name__ == "__main__":
    ft.app(target=main)
```

コンポーネント化する場合の基本のソース構成は↑。
- UserControl を継承してクラスを作る
- メソッド build(self) でコンポーネントの中身を作って return する
- コンポーネントを使う方は、作ったクラスを Control の1つとして生成してレイアウトに組み込む

サンプルでは、Text、TextField、ElevatedButtonをまとめてコンポーネント化しています。
mainでコンポーネントを3つ生成してそのまま配置。

↓がこれを実行した結果です。
同じものが3つ配置され、コンポーネントを変えれば3か所とも変わるような作りになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/d549483a-51bf-8f6a-1506-a73ddcdc285f.png)

## ログインフォームをコンポーネント化（レイアウトのみ）

ログインフォームをコンポーネント化してみたものが↓のサンプルコード。
ID、Passwordを入力してログインボタンを押すという想定でControlを配置。

実行結果が以下です。
Password入力用のプロパティがあるので、簡単に伏字にできます。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/b8eae534-0bdc-de63-d0ea-caaf799bb876.gif)

<details><summary>サンプルコードはこちら</summary>

```python
import flet as ft

class FormLogin(ft.UserControl):
    """Login フォーム"""

    def __init__(self):
        super().__init__()

    def build(self):
        # I/O Controls
        self.tfLoginid = ft.TextField(label="Login ID")
        self.tfPassword = ft.TextField(
            label="Password", password=True, can_reveal_password=True)
        self.btnLogin = ft.ElevatedButton("ログイン")

        # Content
        header = ft.Container(
            ft.Text("ログイン"),
        )
        body = ft.Column(
            [
                self.tfLoginid,
                self.tfPassword,
            ],
        )
        footer = ft.Row(
            [
                self.btnLogin,
            ],
        )

        return ft.Card(
            ft.Container(
                ft.Column(
                    [
                        header,
                        body,
                        footer,
                    ],
                    alignment=ft.MainAxisAlignment.CENTER,
                ),
                width=400,
                padding=30,
            ),
        )


def main(page: ft.Page):

    # I/O Controls
    fromLogin = FormLogin()

    # Page レイアウト
    page.title = "Login form"
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
    page.vertical_alignment = ft.MainAxisAlignment.CENTER
    page.add(fromLogin)


if __name__ == "__main__":
    ft.app(target=main)
```
</details>

## デザイン調整とイベント処理追加

さらにデザイン調整とイベント処理を追加したものが↓のサンプルコード。
「ログイン」ボタンを押したときに main にある login_auth() が呼ばれるようにしてあります。
簡易的に「hoge/hoge」で「ログイン成功」と snack_bar が出るようにしてあります。

実行結果です。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273897/727ff31f-c2b5-bba7-4b64-7ee778b3bd1b.gif)

<details><summary>サンプルコードはこちら</summary>

```python
import flet as ft


class FormLogin(ft.UserControl):
    """Login フォーム"""

    # const
    CONTENT_WIDTH = 400
    CONTENT_HIGHT = 400

    def __init__(self, func_login_auth=None):
        self.func_login_auth = func_login_auth
        super().__init__()

    def build(self):
        # I/O Controls
        self.tfLoginid = ft.TextField(label="Login ID", autofocus=True)
        self.tfPassword = ft.TextField(
            label="Password", password=True, can_reveal_password=True)
        self.btnLogin = ft.ElevatedButton(
            "ログイン", on_click=self.login_clicked)

        # Content
        header = ft.Container(
            ft.Text("ログイン", style=ft.TextThemeStyle.DISPLAY_MEDIUM, weight=ft.FontWeight.BOLD,
                    width=self.CONTENT_WIDTH, text_align=ft.TextAlign.CENTER),
            margin=ft.margin.only(bottom=20),
        )
        body = ft.Column(
            [
                self.tfLoginid,
                self.tfPassword,
            ],
        )
        footer = ft.Row(
            [
                self.btnLogin,
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )

        return ft.Card(
            ft.Container(
                ft.Column(
                    [
                        header,
                        body,
                        ft.Divider(),
                        footer,
                    ],
                    alignment=ft.MainAxisAlignment.CENTER,
                ),
                width=self.CONTENT_WIDTH,
                height=self.CONTENT_HIGHT,
                padding=30,
            ),
        )

    def login_clicked(self, e):
        if self.func_login_auth:
            loginid = self.tfLoginid.value
            password = self.tfPassword.value
            self.func_login_auth(loginid, password)


def main(page: ft.Page):

    def login_auth(loginid, password):
        if loginid == "hoge" and password == "hoge":
            txtMesssage.value = f"ログイン成功！ {loginid} {password}"
            txtMesssage.color = ft.colors.GREEN
        else:
            txtMesssage.value = f"ログイン失敗！ {loginid} {password}"
            txtMesssage.color = ft.colors.RED
        page.snack_bar.open = True
        page.update()

    # I/O Controls
    fromLogin = FormLogin(func_login_auth=login_auth)
    txtMesssage = ft.Text(size=30)

    # Page レイアウト
    page.title = "Login form"
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
    page.vertical_alignment = ft.MainAxisAlignment.CENTER
    page.add(fromLogin)
    page.snack_bar = ft.SnackBar(txtMesssage)


if __name__ == "__main__":
    ft.app(target=main)
```
</details>

## ソース最終形

せっかくコンポーネントにしたので、モーダルダイアログでログインフォームを出すボタンを下に追加。
モーダルダイアログも簡単に出せることを確認できました。

実行結果は概要に貼ってあるものです。

<details><summary>サンプルコードはこちら</summary>

```python
import flet as ft


class FormLogin(ft.UserControl):
    """Login フォーム"""

    # const
    CONTENT_WIDTH = 400
    CONTENT_HIGHT = 400

    def __init__(self, func_login_auth=None):
        self.func_login_auth = func_login_auth
        super().__init__()

    def build(self):
        # I/O Controls
        self.tfLoginid = ft.TextField(label="Login ID", autofocus=True)
        self.tfPassword = ft.TextField(
            label="Password", password=True, can_reveal_password=True)
        self.btnLogin = ft.ElevatedButton(
            "ログイン", on_click=self.login_clicked)

        # Content
        header = ft.Container(
            ft.Text("ログイン", style=ft.TextThemeStyle.DISPLAY_MEDIUM, weight=ft.FontWeight.BOLD,
                    width=self.CONTENT_WIDTH, text_align=ft.TextAlign.CENTER),
            margin=ft.margin.only(bottom=20),
        )
        body = ft.Column(
            [
                self.tfLoginid,
                self.tfPassword,
            ],
        )
        footer = ft.Row(
            [
                self.btnLogin,
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )

        return ft.Card(
            ft.Container(
                ft.Column(
                    [
                        header,
                        body,
                        ft.Divider(),
                        footer,
                    ],
                    alignment=ft.MainAxisAlignment.CENTER,
                ),
                width=self.CONTENT_WIDTH,
                height=self.CONTENT_HIGHT,
                padding=30,
            ),
        )

    def login_clicked(self, e):
        if self.func_login_auth:
            loginid = self.tfLoginid.value
            password = self.tfPassword.value
            self.func_login_auth(loginid, password)


def main(page: ft.Page):

    def open_login(e):
        dlgLogin.open = True
        page.update()

    def close_login(e):
        dlgLogin.open = False
        page.update()

    def login_auth(loginid, password):
        if loginid == "hoge" and password == "hoge":
            txtMesssage.value = f"ログイン成功！ {loginid} {password}"
            txtMesssage.color = ft.colors.GREEN
        else:
            txtMesssage.value = f"ログイン失敗！ {loginid} {password}"
            txtMesssage.color = ft.colors.RED
        page.snack_bar.open = True
        page.update()

    # I/O Controls
    fromLogin = FormLogin(func_login_auth=login_auth)
    txtMesssage = ft.Text(size=30)
    # Add Dialog
    btnLoginDialog = ft.ElevatedButton("ログイン Dialog", on_click=open_login)
    dlgLogin = ft.AlertDialog(
        modal=True,
        title=ft.Text("Login"),
        content=FormLogin(func_login_auth=login_auth),
        actions=[ft.TextButton("Cancel", on_click=close_login)],
        actions_alignment=ft.MainAxisAlignment.END,
    )

    # Page レイアウト
    page.title = "Login form"
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
    page.vertical_alignment = ft.MainAxisAlignment.CENTER
    page.add(fromLogin)
    page.add(ft.Divider())
    page.snack_bar = ft.SnackBar(txtMesssage)
    # Add Dialog
    page.add(btnLoginDialog)
    page.dialog = dlgLogin


if __name__ == "__main__":
    ft.app(target=main)
```
</details>

以上です。