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

今回の記事は、`Flet`のチュートリアル後にWEBレイアウト的なものを試しに作成してみたので、その備忘録です。時間を置くと何を作っていたかも忘れるのでなるはやで作成していきます。

レイアウトとしては、次のような感じ：

![outline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/daa4637c-bc57-d3c5-ce5c-7812918264fb.png)

ナビゲーションバー(Nav Bar)があり、サイドバー(Side Bar)+サイドバーを閉じる・開くボタン(btn)そして本文(Main)といったものです。
Mainについてはどのようなものを実装するか検討中のため、簡単なテキストの出力にとどめました。

今回の作成物のスクショがこちら（ボタン名などはかなり適当なのでご注意）：

![example.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/755eabc7-dd30-eb52-6fb6-ed2dd8a17f61.png)

今回の記事で参照した記事は

 * [Fletドキュメント](https://flet.dev/docs/)
 * [Fletで実践的なアプリ作成に向けて！Googleアカウント認証と表示制御から](https://qiita.com/eno49conan/items/a79b2a29fee08e249a27)

で主にFletドキュメントのチュートリアルから引っ張ってきています。

## 1. Main (main.py)
まず、Fletを使用する際のスタートラインは

```python
import flet
from flet import Page
 
def main(page: Page):
    page.title = "Example"
    text = Text("Hello Flet")
    page.controls.append(text)
    page.update()

flet.app(target=main)
```

となります。上記処理は

 * `Text`オブジェクトのインスタンス生成(`text`)
 * `Page`オブジェクトの`controls:list`に`text`を追加
 * `Page`オブジェクトの`update`メソッドの呼び出し

を行います。この処理と等価な処理が

```python
import flet
from flet import Page, Text

def main(page: Page):
    page.title = "Example"
    text = Text("Hello Flet")
    page.add(text)

flet.app(target=main)
```

です。基本的には`Page#add()`を使用していく方が便利です。（チュートリアルでも`add`メソッドを使用しています。）

このように、`Page`オブジェクトにテキストや画像などを追加していき、画面を作成していきます。

以下でコードを記載しますが、
 
 * main.py
 * header.py
 * sidebar.py

が同階層にあることを想定して、`main.py`を次のように記載しました。

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

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


flet.app(target=main)
```

`AppHeader`および`Sidebar`は後述するとして、新しく`flet#Row()`オブジェクトを導入しました。行にあたる`Row`があるのであれば列にあたる`Column`もあります。（`flet#Column()`）これらは、今回のようにレイアウトを作成したいときに非常に便利なオブジェクトで、ざっくり

 * `Row` : 横方向にオブジェクトを並べる 
 * `Column` : 縦方向にオブジェクトを並べる

といったことが可能です。`flet#Row#controls`の中に、オブジェクトを設定していきます。このリストの中に入れた順番に表示されるはずです。
細かい、設定については上記コードにあるように`tight`や`expand`といったものを参照します。


## 2. Nav Bar (header.py)

導入部分で紹介した図では、Nav Barにいくつかオブジェクトを並べていましたが、初めは表示させることのみに着目してコードを記載していきます。そのため、`header.py`とは若干脱線します。基本について習熟済みの場合はこちらは読み飛ばしてください。

まず、Nav Barのメインは`flet#AppBar()`となります。こちらをインポートしインスタンス生成、`Page`オブジェクトに追加することでナビゲーションバーを実装することができます：

```python
import flet
from flet import (
    AppBar,
    colors,
    ElevatedButton,
    Icon, 
    icons,
    Page,
    Text, 
)

def main(page: Page):
    app_bar = 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, # ナビゲーションバーの背景色
    )
    page.add(app_bar)

flet.app(target="main")
```

このようにすると、画面上部にナビゲーションバーが表示されます。
しかし、この方法で`Text`を追加するとエラーが発生します。
理由について[公式ドキュメント](https://flet.dev/docs/)では見つけられなかったので、ChatGPTで`Flutter`について同様な内容がないかどうか聞いてみたところ、

>Flutterにおいて、AppBarとTextを同じコントロール内に直接配置することはできません。AppBarはScaffoldウィジェットの一部であり、通常のコンテンツとして配置することができません。

と返答が来ました。`Scaffold`は`flet`のドキュメントで検索しても出てこないので正確な回答ではありませんが、`Flutter`の`AppBar`と同様に`flet`の`AppBar`は`Text`などと同じコントロールに配置することができません。そのため、`Page#appbar`に`AppBar`を配置することでこの問題を回避します。（公式ドキュメントではこちらで書かれていました。）なので、上記コードを以下のように書き直します：

```python
import flet
from flet import (
    AppBar,
    colors,
    ElevatedButton,
    Icon, 
    icons,
    Page,
    Text, 
)

def main(page: Page):
    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,
    )
    page.add(
        Text("Main content")    
    )

flet.app(target="main")
```

このように設定することでナビゲーションバーとメインが表示されるはずです。
他にもないかと調べてみたところ、`Page#banner`や`Page#dialog`が見つかりましたが、`AppBar`のような事象は特になかったです。
参考までに、ナビゲーションバーにバナーを表示させるコードが以下になります。（サンプルからとってきたものでバナーを表示させるトリガーはボタンのクリックになります）：
```python: バナーの例（後の実装には追加していません。）
import flet
from flet import (
    AppBar,
    Banner,
    colors,
    ElevatedButton,
    Icon, 
    icons,
    Page,
    Text,
    TextButton,
)

def main(page: Page):
    def close_banner(e):
        page.banner.open = False
        page.update()

    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,
    )

    page.banner = Banner(
        bgcolor=colors.AMBER_100,
        leading=Icon(icons.WARNING_AMBER_ROUNDED, color=colors.AMBER, size=40),
        content=Text(
            "Oops, there were some errors while trying to delete the file. What would you like me to do?"
        ),
        actions=[
            TextButton("Retry", on_click=close_banner),
            TextButton("Ignore", on_click=close_banner),
            TextButton("Cancel", on_click=close_banner),
        ],
    )

    def show_banner_click(e):
        page.banner.open = True
        page.update()

    page.add(ElevatedButton("Show Banner", on_click=show_banner_click))

flet.app(target=main)
```

上記のコードではざっくり：

 * `Page`オブジェクトの`banner`に`Banner`オブジェクトをセット
 * バナーは`Banner#open`が`True`の時に表示され、`False`では表示されない
 * `on_click`イベント、つまりボタンのクリックによってバナーの表示を制御している
   * "Show Banner"ボタンを押下 → バナー表示
   * "Retry/Ignore/Cancel"ボタンを押下 → バナー非表示

余談として、`Page#banner`ではなく、`Page#add()`によってバナーを追加した場合（上記コードで`page.`を除く）でもアプリケーションは正常に動きました。ただし、`controls=[ElevatedButton, Banner]`の順番で追加したとしても表示されるのはナビゲーションバーの下部でした。

>Banners are displayed at the top of the screen, below a top app bar.

（ドキュメント通りです。はい。）

（ここから、`header.py`の内容になります。）

さて、ナビゲーションバーにボタンやらいろいろ追加していきます。こちらは`AppBar#actions`に記載していくことで実装ができます。
```python: header.py
import flet
from flet import (
    AppBar,
    colors,
    Container,
    ElevatedButton,
    Icon, 
    IconButton,
    icons,
    margin,
    Page,
    PopupMenuButton,
    PopupMenuItem,
    Row,
    Text,
)

def main(page: Page):
    
    def toggle_icon(e):
        page.theme_mode = "light" if page.theme_mode == "dark" else "dark"
        toggle_dark_light_icon.selected = not toggle_dark_light_icon.selected
        page.update()

    toggle_dark_light_icon = IconButton(
        icon="light_mode",
        selected_icon = "dark_mode",
        tooltip=f"switch light / dark mode",
        on_click=toggle_icon,
    )
    appbar_items = [
        PopupMenuItem(text="Login"),
        PopupMenuItem(), # divider
        PopupMenuItem(text="Settings"),
    ]
    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(
                    [
                        toggle_dark_light_icon,
                        ElevatedButton(text="Button"),
                        PopupMenuButton(
                            items=appbar_items
                        ),
                    ],
                    alignment="spaceBetween",
                ),
                margin=margin.only(left=50, right=25)
            )
        ]
    )

    page.update()

flet.app(target=main)
```

上記コードでは、

 * `ElevatedButton`
 * `PopupMenuButton(PopupMenuItem)`
 * `IconButton`

といったボタンを追加しました。それぞれ、クリックイベントを追加することができますが、実装しているのはライトまたはダークテーマに入れ替えるためのボタンのみです。イベントの実装はほとんど[こちら](https://qiita.com/eno49conan/items/a79b2a29fee08e249a27)をまねたものです。`toggle_icon`メソッドは

  * `Page#theme_mode` : `SYSTEM (default)`, `LIGHT` or `DARK`
  * ボタンを押下したときのテーマに合わせて、以下のようにテーマを入れ替える：
    * ダークモード → ライトモード
    * ライトモード → ダークモード
  * `IconButton#tooltip`に値を入れることで、ボタンをマウスホバーした際に、その値を表示する（説明を表示する場合に使用。ほかのボタンも同じく設定可能）

といったものです。


## 3. Side Bar (sidebar.py)

サイドバーを作成するために、`NavigationRail(NavigationRailDestination)`を使用します。サイドバーは今回は左側に設置しますが、`Row`を上手く設定すれば右側に設置することも可能です。
さて、サイドバーを表示するコードは以下の通りです：
```python: sidebar.py
import flet
from flet import (
    alignment,
    border_radius,
    colors,
    Container,
    FloatingActionButton,
    Icon,
    IconButton,
    icons,
    NavigationRail,
    NavigationRailDestination,
    NavigationRailLabelType,
    Page,
    Row,
    Text,
)

def main(page: Page):
    
    def toggle_nav_rail(e):
        nav_rail.visible = not nav_rail.visible
        toggle_nav_rail_button.selected = not toggle_nav_rail_button.selected
        toggle_nav_rail_button.tooltip = "Open Side Bar" if toggle_nav_rail_button.selected else "Collapse Side Bar"
        page.update()

    nav_rail_items = [
        NavigationRailDestination(
            icon=icons.FAVORITE_BORDER,
            selected_icon=icons.FAVORITE,
            label="First"
        ),
        NavigationRailDestination(
            icon_content=Icon(icons.BOOKMARK_BORDER),
            selected_icon_content=Icon(icons.BOOKMARK), 
            label="Second"
        ),
        NavigationRailDestination(
            icon=icons.SETTINGS_OUTLINED, 
            selected_icon_content=Icon(icons.SETTINGS), 
            label_content=Text("Settings"),
        ),
    ]
    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, # -1.0から1.0の間の値により整列方法を指定(-1.0(default):ページ上部, 0.0:ページ中央, 1.0:ページ下部)
        destinations=nav_rail_items,
        on_change=lambda e: print("Selected destination: ", e.control.selected_index),
    )
    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=toggle_nav_rail,
        tooltip="Collapse Nav Bar",   
    )

    view = Container(
        content=Row(
            controls=[
                nav_rail, # サイドバー本体
                Container( # サイドバーとボタンの区切り線
                    bgcolor=colors.BLACK26,
                    border_radius=border_radius.all(30),
                    height=220,
                    alignment=alignment.center_right,
                    width=2
                ),
                toggle_nav_rail_button, # サイドバーの表示・非表示を制御するためのボタン
            ],
            expand=True, # 画面の空白部分を補うかどうか
            vertical_alignment="start", # 画面上部より表示
        )
    )
    page.add(view)


flet.app(target=main)
```

上記コードを少しずつ説明しますが、ざっくりというと`view`を`Page`に渡すことでサイドバーが表示されます。なので、`view`の中身について深堀していきます。
まず、複数の要素をまとめておりますが細かい内容になるのでこちらは後回し、そしてレイアウトを設定するために`flet#Container`を使用していることに注意してください。今回、大まかな使用用途は

 * レイアウトの設定
 * 区切り線

となります。
`view`の中身を確認してみると、`nav_rail`、`Container(区切り線)`そして`toggle_nav_rail_button`が`Row`の`controls:list`に順番に挿入されているのことがわかります。（サイドバー→区切り線→ボタン）

次に、`nav_rail`を確認してみます。`NavigationRail`の中を確認してみると`leading`にボタン、`destinations`に`nav_rail_items(NavigationRailDestination)`とあります。これがサイドバーのボタンです。`leading`のボタンにはイベントは追加されていませんが、`destinations`のボタンにはイベントが追加されています。
ここで、`on_change`イベントはボタンが未選択・選択状態になることでイベントが発火し、ここでは選択されたボタンが`nav_rail_items`の何番目のボタンであるかをターミナルに表示しています。余談ですが、コード内の`print(e.control)`としてターミナルに出力させると、`nav_rail`の設定値が確認できます。アクセス方法は、`NavigationRail`のプロパティを指定すればよいです。以下は出力される内容です：

```python
Selected destination:  navigationrail {'height': 300, 'labeltype': 'all', 'minwidth': 100, 'minextendedwidth': 400, 'groupalignment': -0.9, 'selectedindex': '0'}
```

最後に、`toggle_nav_rail_button`ですが、こちらにはクリックイベントが追加されています。メソッド`toggle_nav_rail`は以下のように処理をします：

 * `NavigationRail#visible`は`True`のとき表示、`False`のとき非表示
 * 上記、性質を用いてイベントが走ったときに、`True`なら`False`に、`False`なら`True`に変更
 * 同時に、`tooltip`を「サイドバーを閉じる」から「サイドバーを開く」という表現に変更
 * `Page`の更新

サイドバーが非表示になるので（区切り線→ボタン）という状態に変化します。
このようにしてサイドバーを閉じたり、開いたりすることができます。
（なお、`NavigationRail#visible`が公式ドキュメントに記載がない。何故？）


## 4. まとめ

今回はナビゲーションバーとサイドバーおよびその周辺で使用したものについて紹介しました。現在のままでは、少しコードが長くなりすぎている、加えて`main.py`とかみ合っていない状態です。（紹介のために`class`バージョンだと`self.`が加わって見ずらいのですべて取っ払いました。あとは自分のための復習ですね。）
次回の記事では、レイアウト要素を`class`バージョンに書き直し、最終的な実装物を紹介できたらと考えています。