【Flet】Fletで簡単なWEBアプリケーションのレイアウトを作成（Class ver.）

:::note warn
警告
以下の記事はFletのバージョン`0.8.x`で作成したものです。本注意を記述した現在の最新バージョンは`0.22.1`（2024/05/21）であり、Class記法において`UserControl`は非推奨、加えてClassの定義で必ず`build()`を実装しなければならなかった点が変更となっています。以上の点から本記事とは別に最新のバージョンでの記法に修正した内容を投稿予定です。
:::

今回は次の記事：

https://qiita.com/oriefield/items/473049ca2cbc05ce4c6a

をクラス型で定義しなおすといった内容です。
Pythonのクラス型に関しては前回の記事：

https://qiita.com/oriefield/items/29e68ba889778e2058bf

を参考までに。

記事は前回の流れを踏襲して、

1. Main (main.py)
2. Nav Bar (header.py)
3. Side Bar (sidebar.py)

の順に紹介します。

# 1. Main (main.py)
アプリケーションを起動するメイン(Main)部分は`main.py`とします。このモジュールでは

 * `header.py`のインポート
 * `sidebar.py`のインポート
 * ヘッダー・サイドバー・メインの3構成でレイアウトを作成
 * アプリケーションの起動

といった処理を行います。以前紹介した記事の内容と変わりません：

```python: main.py
import flet
from header import AppHeader
from sidebar import Sidebar
from flet import (
    Page,
    Row,
    Text,
)

def main(page: Page):
    page.title = "Example Layout"
    page.padding = 10
    my_text = Text("Main Body")
    # インスタンス作成時にpage.appbarにナビゲーションバーが設定される
    AppHeader(page)
    sidebar = Sidebar()
    layout =  Row(
        controls=[sidebar, my_text],
        tight=True, # 水平方向の隙間をどうするか。デフォルトはFalseですべての要素に余白を与える。
        expand=True, # 利用可能なスペースを埋めるようにするか。
        vertical_alignment="start", # 画面上部から表示。ほかに"center"や"end"などの値がある。
    )

    page.add(layout)
    page.update()

flet.app(target=main)
```

メイン部分は簡単なテキストを表示させるだけになっています。

# 2. Nav Bar (header.py)

FletコンポーネントのClassを定義する際に、`UserControl`をClass定義の引数に渡します。（こちらが基底クラスとなります。）
Classの内部では`Text`や`ElevatedButton`などの要素を定義します。ここで、`UserControl`についていくつか注意すべき点について次の通り：

 * `UserControl`は再利用可能な独立したコンポーネントのビルドを行うためのクラスであり、ビルドを行うために必ず`build()`メソッドを実装する必要がある。
 * `UserControl`は独立したコンポーネントであるため、Pageに対する変更に対しても変更を反映するために、`self.update()`を呼び出す。（独立したコンポーネントは自身を更新できるように実装すべし）
 * `UserControl`を基底クラスとした派生クラスのコンストラクターは必ず、`super().__init__()`を呼び出す。

つまり、関数形式からクラス形式に転じる際の変更点は

 * `def` → `class`
 * コンストラクター`__init__()`の実装
 * ビルド`build()`の実装
 * クラスオブジェクトのメンバに各コンポーネントを割り当てる

といったことになります。4つ目が個人的には一番面倒なところにはなりますね。（`self`を何度も使用するので）
以上の点を考慮して実装したコードが次の通り：

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

class AppHeader(UserControl):
    def __init__(self, page: Page):
        super().__init__()
        self.page = page
        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,
        )
        something_btn = ElevatedButton(text="Button")
        self.appbar_items = [
            PopupMenuItem(text="Login"),
            PopupMenuItem(), # divider
            PopupMenuItem(text="Settings"),
        ]
        # Appのpage.appbarフィールドの設定
        self.page.appbar = AppBar(
            leading=Icon(icons.TRIP_ORIGIN_ROUNDED),
            leading_width=100,
            title=Text(value="Example", size=32, text_align="center"),
            center_title=False,
            toolbar_height=75,
            bgcolor=colors.SURFACE_VARIANT,
            actions=[
                Container(
                    content=Row(
                        [
                            self.toggle_dark_light_icon,
                            something_btn,
                            PopupMenuButton(
                                items=self.appbar_items
                            ),
                        ],
                        alignment="spaceBetween",
                    ),
                    margin=margin.only(left=50, right=25)
                )
            ],
        )

    def build(self):
        return self.page.appbar

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

※コンストラクター`__init__(self, page)`について、クラスを呼び出す際には`AppHeader(page)`とします。第1引数の`self`はインスタンスオブジェクトが渡されているのですが、こちらの事情について曖昧の場合はPythonのクラスについて復習されることをお勧めします。参考までに[こちら](https://qiita.com/oriefield/items/29e68ba889778e2058bf)を参照ください。

ヘッダー部分の内容については前回の関数形式の場合との変更点は特にないです。

# 3. Side Bar (sidebar.py)

サイドバーの実装についてもヘッダーと同様に、変更していきます。変更後は次の通り：

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


class Sidebar(UserControl):
    def __init__(self):
        super().__init__()
        self.nav_rail_visible = True
        self.nav_rail_items = [
            NavigationRailDestination(
                icon_content=Icon(icons.BOOKMARK_BORDER),
                selected_icon_content=Icon(icons.BOOKMARK), 
                label="Second"
            ),
            NavigationRailDestination(
                icon=icons.FAVORITE_BORDER,
                selected_icon=icons.FAVORITE,
                label="First"
            ),
            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",   
        )

    def build(self):
        self.view = Container(
                content=Row(
                [
                    self.nav_rail,
                    Container( # vertical divider
                        bgcolor=colors.BLACK26,
                        border_radius=border_radius.all(30),
                        height=220,
                        alignment=alignment.center_right,
                        width=2
                    ),
                    self.toggle_nav_rail_button,
                ],
                expand=True,
                vertical_alignment=CrossAxisAlignment.START,
                visible=self.nav_rail_visible,
            )
        )
        return self.view

    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.view.update()
        self.page.update()
```

関数形式からクラス形式にするにあたって、特に処理の変更なし。

# 4. Main(main.py)の実行

`1. Main (main.py)`から`3. Side Bar (sidebar.py)`までの工程でコンポーネントをそれぞれ作成することができたので、`main.py`を実行すれば前回と同じように次の表示がされるはずです：

![example2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/3e8f11b7-ab25-90de-02a9-b781e22e3449.png)
]

上記画像は、前回の記事の変更点として

 * Mainの箇所が`Main Body`に変更
 * 右上の「太陽のアイコン」をクリック後

です。

後は、

 * 各種ボタンに対して適切な名前の追加・アイコンの変更、イベントの追加を行う
 * `Main Body`部分にいい感じの実装をする

といったことをすればよさそうです。今回はレイアウトが作れればOKなのでここいらで完了。

# 5. 少しお遊び

`Main Body`に追加する要素について、簡単なサンプルをこちらで記述。イベントの取得などを試してみるといった観点で作成しました。まずは、適当なモジュールでお試し：

```python: sample.py
def main(page: Page):
    hist = []
    def add_element(e):
        value = e.control.data["key"]
        hist.append(Text(value=f"ELEMENNT ADDED {value}"))
        add_elements.controls.clear()
        add_elements.controls.append(hist[-1])
        page.update()
    
    def del_element(e):
        if len(hist) != 0:
            hist.pop()
            add_elements.controls.clear()
            page.update()
        if len(hist) >= 1:
            add_elements.controls.append(hist[-1])
            page.update()

    btn_1 = ElevatedButton(text="Button1", on_click=add_element, data={"key": "value1"})
    btn_2 = ElevatedButton(text="Button2", on_click=add_element, data={"key": "value2"})
    btn_3 = ElevatedButton(text="Button3", on_click=add_element, data={"key": "value3"})
    btn_del = ElevatedButton(text="Delete", on_click=del_element)

    add_elements = Column()

    layout = Column(
        controls=[
            Row(
                controls=[btn_1, btn_2, btn_3],
                alignment="center",
            ),
            btn_del,
            add_elements
        ]
    )
    page.add(layout)
    page.update()


flet.app(target=main)
```

こちらのコードは

 * 追加ボタンを3つ（サイドバーのボタンで何かできるか？）
 * 追加ボタンには`text`、`on_click`そして`data`属性を設定
 * 削除ボタンを1つ
 * 削除ボタンには`text`、`on_click`を設定
 * 空の`Column`要素（ボタン押下で要素を追加する用）
 * ボタンクリックイベント用の関数`add_element`
 * 画面要素を格納するリスト`hist`（履歴みたいに使用できるか？。スコープは`main`内部）

からなっています。

**試したこと**

 1. `on_click`属性を使用して要素を追加
 2. イベントが発火されたときに渡される引数`e`の中身を調べる：
    * `e` : オブジェクト情報（人が読めるのは同じかそうでないか程度）
    * `e.control` : クラス名とその引数に関する情報
    例：`elevatedbutton {'text': 'Button1'}`
    * `e.control.text` : `text`属性の値
    例：`Button1`
    * `e.control.data` : `data`属性の値
    例： `{'key': 'value1'}`

3. `hist`に格納された`Text`の要素の末尾を画面表示、それ以外は履歴として残す。残された履歴は`Delete`ボタンで末尾を削除されると末尾の前の要素が表示される。（戻るボタンに近い。）
例：
【手順】以下の順番でボタンをクリックする。`Button1`→`Button2`→`Button3`→`Delete`→`Delete`→`Delete`
【確認】`Delete`ボタンの下の表示は次のようになるはず。
`ELEMENNT ADDED value1`→`ELEMENNT ADDED value2`→`ELEMENNT ADDED value3`→`ELEMENNT ADDED value2`→`ELEMENNT ADDED value1`→非表示（`hist`が空）


上記のようなものを使用して、サイドバーとかのボタンで画面遷移を行うようにしていきたい。

# 6. まとめ

今回は簡単にだが、[前回の記事](https://qiita.com/oriefield/items/473049ca2cbc05ce4c6a)を関数形式からクラス形式の記述に変更した。この変更により、特定のコンポーネントを作成し、それを流用して使用するといったことが可能となる。（今回はレイアウトでしたが、カードといった要素があるのでその1要素をクラスで定義しておくと便利）
レイアウトの作成ができたので、次回以降はほかのコンポーネントの定義または画面遷移の実装などを実施していきたい。