【Flet】WEBレイアウトをClassで作成

今回の記事は[以前の記事](https://qiita.com/oriefield/items/c832e28c92720acafaef)をFletのバージョン0.21.X以降で`UserControl`が非推奨となったことを契機に焼き直しを図ることを目的としています。
まずはFletのバージョン更新に伴うClass形式の記述方法の変更点について紹介し、その後以前作成したコードを更新していきます。

本記事を作成するにあたって以下を参照しました。

* [Flet FastAPI and async API improvements](https://flet.dev/blog/flet-fastapi-and-async-api-improvements)
* [【Flet】Fletで簡単なWEBアプリケーションのレイアウトを作成（Class ver.）](https://qiita.com/oriefield/items/c832e28c92720acafaef)

# 1. FletにおけるClass形式の記述方法

> Flet makes writing dynamic, real-time web apps a real fun!
>
> Flet 0.21.0 further improves web apps development experience as well as using asyncio APIs in your Flet apps.
>
> Here's what's new in Flet 0.21.0:
> -- 中略 --
> In this Flet release we also re-visited API for writing custom controls in Python.
>
> As a result `UserControl` class has been deprecated. You just inherit from a specific control with layout that works for your needs.

平たく言うと、Fletのバージョン0.21.0以降では`UserControl`を用いたClass宣言が非推奨となり、特定のコンポーネント（例えば、`Text`）を用いて宣言する。以下は、同サイトで紹介されているコードを少し書き換えたものです。

```countdown.py
import asyncio
from flet import (
    app,
    Text,
    Page,
)

class Countdown(Text):
    def __init__(self, seconds):
        super().__init__()
        self.seconds = seconds

    def did_mount(self):
        self.running = True
        self.page.run_task(self.update_timer)

    def will_unmount(self):
        self.running = False

    async def update_timer(self):
        while self.seconds and self.running:
            mins, secs = divmod(self.seconds, 60)
            self.value = "{:02d}:{:02d}".format(mins, secs)
            self.update()
            await asyncio.sleep(1)
            self.seconds -= 1

def main(page: Page):
    page.add(Countdown(120), Countdown(60))


if __name__ == '__main__':
    app(target=main)
```

バージョン0.21.0以前では

```
class Countdown(UserControl):
    中略
    def build(self):
        return Text(...)
```

のように`build()`メソッドを必ず実装する必要がありましたが、アップデートにより不要となり記述がすっきりしました。
以降の最新化では基本的に`UserControl`,`build()`を消去、コンストラクター`__init__()`でコンポーネントの定義を行っていきます。


# 2. コードの最新化
以前記述したコードよりもわかりやすいく分割するため以下のようなフォルダ構成にしました。

```text:フォルダ構造
root.
|   .gitignore
|   layout.py
|   main.py
|   README.md
|   requirements.txt
|   
+---components
|   |   body.py
|   |   header.py
|   |   sidebar.py   
```

まず、rootフォルダを作成するために

```
flet create <folder_name>
```

を実行しました。`assets`フォルダなど作成されますが今回は不要なので削除しています。
ファイルの相関としては`body.py, header.py, sidebar.py -> layout.py -> main.py`といった感じで`main.py`は基本的に`layout.py`を呼び出すだけにしました：

```python:main.py
from layout import MyLayout
from flet import (
    app,
    Page,
)

def main(page: Page):
    page.title = "Example Layout"
    page.padding = 10

    page.add(MyLayout(page))

if __name__ == '__main__':
    app(target=main)
```

次に`layout.py`ですが、こちらは`main.py`を簡潔に記述する目的で作成しました。

```python:layout.py
from components.body import ContentBody
from components.header import AppHeader
from components.sidebar import Sidebar
from flet import (
    CrossAxisAlignment,
    MainAxisAlignment,
    Row,
    Page,
)

class MyLayout(Row):
    def __init__(self, page: Page):
        super().__init__()
        self.page = page
        self.alignment=MainAxisAlignment.START
        self.vertical_alignment=CrossAxisAlignment.START

        AppHeader(page, 'WEB LAYOUT') # インスタンス生成時にpage.appbarにナビゲーションバーが設定される
        self.controls = [ Sidebar(), ContentBody() ]
```

MyLayoutはヘッダー、サイドバー、メインボディの３部構造で画面を作成するように設定しています。componentsフォルダからbody, header, sidebarモジュールをインポートし、ヘッダーを作成（AppHeader()をインスタンス生成）し、`Row.controls`に`Sidebar()`と`ContentBody()`の順にそれぞれ渡しています（`Row`はその名の通り、横にコンポーネントを並べます。）。
レイアウト自体は非常にシンプルですね。

最後に、コンポーネントを順に焼き直していきます。
まずは、ボディから。

```python: body.py
from flet import (
    Column,
    Text,
)

class ContentBody(Column):
    def __init__(self):
        super().__init__()
        self.spacing = 10
        self.controls = [
            Text('**********************'),
            Text('This is Main Body.'),
            Text('**********************'),
        ]
```

`Text()`を`Column()`の`controls`に並べているだけです（`Column`は縦に`Text()`を並べてくれます）。

次に、ヘッダー。

```python: header.py
from flet import (
    AppBar,
    colors,
    Container,
    ElevatedButton,
    Icon,
    IconButton,
    icons,
    MainAxisAlignment,
    margin,
    Page,
    PopupMenuButton,
    PopupMenuItem,
    Row,
    Text,
)

class AppHeader(AppBar):
    def __init__(self, page: Page, page_title: str="Example"):
        super().__init__()
        self.page = page
        self.page_title = page_title
        self.toggle_dark_light_icon = IconButton(
            icon="light_mode",
            selected_icon = "dark_mode",
            tooltip=f"switch light and dark mode",
            on_click=self.toggle_icon,
        )
        self.appbar_items = [
            PopupMenuItem(text="Login"),
            PopupMenuItem(),
            PopupMenuItem(text="SignUp"),
            PopupMenuItem(),
            PopupMenuItem(text="Settings"),
        ]
        # Appのpage.appbarフィールドの設定
        self.page.appbar = AppBar(
            leading=Icon(icons.TRIP_ORIGIN_ROUNDED),
            leading_width=100,
            title=Text(value=self.page_title, size=32, text_align="center"),
            center_title=False,
            toolbar_height=75,
            bgcolor=colors.SURFACE_VARIANT,
            actions=[
                Container(
                    content=Row(
                        [
                            self.toggle_dark_light_icon,
                            ElevatedButton(text="SOMETHING"),
                            PopupMenuButton(
                                items=self.appbar_items
                            ),
                        ],
                        alignment=MainAxisAlignment.SPACE_BETWEEN,
                    ),
                    margin=margin.only(left=50, right=25)
                )
            ],
        )

    def toggle_icon(self, e):
        self.page.theme_mode = "light" if self.page.theme_mode == "dark" else "dark"
        self.toggle_dark_light_icon.selected = not self.toggle_dark_light_icon.selected
        self.page.update()
```

ボディと比較すると幾分複雑ですが、`Page.appbar`に対して`AppBar()`を設定しており、ほかはその詳細なレイアウトの設定となっています。非常に簡単なヘッダーで問題なければ

```python
self.page.appbar = AppBar(
            title=Text(value=self.page_title, size=32, text_align="center"),
            center_title=False,
            toolbar_height=75,
            bgcolor=colors.SURFACE_VARIANT,
        )
```

とすればページタイトルと背景色、ヘッダーの高さのみを設定したシンプルなヘッダーが出来上がります。（それ以外は、プルダウンやロゴなどヘッダーをリッチにしている感じです。）後はコンストラクターで`super().__init__()`, `self.page=page`と`self.page_title=page_title`を設定するのだけお忘れなく。

最後に、サイドバーです。

```python: sidebar.py
from flet import (
    alignment,
    border_radius,
    colors,
    Container,
    CrossAxisAlignment,
    FloatingActionButton,
    Icon,
    IconButton,
    icons,
    NavigationRail,
    NavigationRailDestination,
    NavigationRailLabelType,
    Row,
    Text,
)

class Sidebar(Container):
    def __init__(self):
        super().__init__()
        self.nav_rail_visible = True
        self.nav_rail_items = [
            NavigationRailDestination(
                icon=icons.FAVORITE_BORDER,
                selected_icon=icons.FAVORITE,
                label="Favorite"
            ),
            NavigationRailDestination(
                icon_content=Icon(icons.BOOKMARK_BORDER),
                selected_icon_content=Icon(icons.BOOKMARK),
                label="Bookmark"
            ),
            NavigationRailDestination(
                icon=icons.SETTINGS_OUTLINED,
                selected_icon_content=Icon(icons.SETTINGS),
                label_content=Text("Settings"),
            ),
        ]
        self.nav_rail = NavigationRail(
            height= 300,
            selected_index=None,
            label_type=NavigationRailLabelType.ALL,
            min_width=100,
            min_extended_width=400,
            leading=FloatingActionButton(icon=icons.CREATE, text="ADD"),
            group_alignment=-0.9,
            destinations=self.nav_rail_items,
            on_change=lambda e: print("Selected destination: ", e.control.selected_index),
        )
        self.toggle_nav_rail_button = IconButton(
            icon=icons.ARROW_CIRCLE_LEFT,
            icon_color=colors.BLUE_GREY_400,
            selected=False,
            selected_icon=icons.ARROW_CIRCLE_RIGHT,
            on_click=self.toggle_nav_rail,
            tooltip="Collapse Nav Bar",
        )
        self.visible = self.nav_rail_visible
        self.content = Row(
            controls=[
                self.nav_rail,
                Container(
                    bgcolor=colors.BLACK26,
                    border_radius=border_radius.all(30),
                    height=480,
                    alignment=alignment.center_right,
                    width=2
                ),
                self.toggle_nav_rail_button,
            ],
            vertical_alignment=CrossAxisAlignment.START,
        )


    def toggle_nav_rail(self, e):
        self.nav_rail.visible = not self.nav_rail.visible
        self.toggle_nav_rail_button.selected = not self.toggle_nav_rail_button.selected
        self.toggle_nav_rail_button.tooltip = "Open Side Bar" if self.toggle_nav_rail_button.selected else "Collapse Side Bar"
        self.update()
```

`Container.content`にナビゲーションバー、ディバイダー（分割線）とナビゲーションバーのトグルボタンを横並びに設定しています（ディバイダーがなくても問題なく、デザイン次第ですね。）。トグルボタンを押下するとナビゲーションバーが閉じたり、開いたりするイベントが`toggle_nav_rail()`メソッドにより設定されています。


# 3. まとめ

今回の記事は前回作成した記事の復習および整理になっていますが、まとめたおかげで画面遷移などの実装のイメージが付きやすくなった印象です。たまには整理が重要だと感じた次第です。