【Flet】Routeを使用して、画面切り替えを実装

今回の記事は[前回の記事](https://qiita.com/oriefield/items/35952ffd4a7d32cbe08b)を使用して、画面切り替え機能を実装したのでその備忘録となります。

実装概要としてはサイドバーのアイコンをクリックしたとき、メインボディの内容を切り替えるといった内容です。
※切り替える際に`Page.route`の情報が必要なため、各コンポーネントに`Page`を渡しています。

本記事を作成するにあたり以下を参照しました：

* [【Flet】WEBレイアウトをClassで作成](https://qiita.com/oriefield/items/35952ffd4a7d32cbe08b)
* [Navigation and routing](https://flet.dev/docs/getting-started/navigation-and-routing)

# 1. 実装物の挙動

・本プログラムを実行すると以下の画面が表示されます：

![スクリーンショット 2024-05-24 180521.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/c224b068-95c9-f6a9-748b-fd9026d9fc16.png)

・←ボタンを押下するとサイドバーが閉じます（→ボタンが表示されて、そのボタンを押すとサイドバーが再度表示されます。）：

![スクリーンショット 2024-05-24 180105.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/abd6fec9-d008-b69b-9ac7-7d44d59a3b89.png)

↓

![スクリーンショット 2024-05-24 180127.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/92a6bc17-ceab-c7f7-5481-53dc9f33978c.png)

・Favoriteボタンを押下するとヘッダーのタイトルと本文が切り替わります：

![スクリーンショット 2024-05-24 180521 - コピー (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/f8a47b00-7e90-9e13-cda4-d84fe3e86c7a.png)

↓

![スクリーンショット 2024-05-24 180140.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/3cec554a-148e-0269-cc17-5e641a323377.png)

※ヘッダー右側のライトモードまたはダークモードを切り替えるボタンは実装済みですが、それ以外は表示のみです。

# 2. コード概要

画面切り替えを行うため`View`コンポーネントを使用しました。ファイル構造は以下のようになります（Route用に`views.py`を作成しています。）：

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

新規作成した`views.py`はRoute変更時の挙動を管理するために実装しており、詳しくは[こちら](https://flet.dev/docs/getting-started/navigation-and-routing)を参照ください。

以前の記事からの変更点としては

* `main.py` : `layout.MyLayout`から`views.MyView`に変更。
* `views.py` : 新規作成（Route及び画面管理）。
* `layout.py` : コンポーネント`View`を追加し、Routeを定義。
* 他コンポーネント : `header.py`と`sidebar.py`はRoute情報を取得するため、`Page`を引数に追加。`body.py`は本文のテキスト用に引数追加。

となっています。`views.py`で画面切り替えを実装し、`layout.py`でRouteに依存して表示するコンポーネントを変更するように修正しています。

# 3. 実装詳細

```python:main.py
from views import MyView
from flet import (
    app,
    Page,
)

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

    MyView(page)

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

`views.MyView`をインポートし、`MyView(page)`をインスタンス生成しています。

```python: views.py
from layout import MyLayout
from flet import (
    Row,
    Page,
)

class MyView(Row):
    def __init__(self, page: Page):
        super().__init__()
        self.page = page
        self.page.on_route_change = self.route_change
        self.page.on_view_pop = self.view_pop
        self.page.go(self.page.route)

    def route_change(self, route):
        self.page.views.clear()
        self.page.views.append(MyLayout(self.page, self.page.route))
        self.page.update()

    def view_pop(self, view):
        self.page.views.pop()
        top_view = self.page.views[-1]
        self.page.go(top_view.route)
```

`page`に対して、Route変更、Routeが変更されたときのイベントおよびviewを切り替えるイベントを実装しています。
`route_change`の中で、`Page.views`に`MyLayout(page, route)`を追加しているのでRouteに依存して画面を切り替えるようにできます。
`view_pop`は`views`のリストに対して以下のようにViewを追加または削除するように挙動します：

```text:イメージ
views = [..., 'HOME', 'ABOUT'] -- POP --> [..., 'HOME'] -- views[-1] --> 'HOME'
```

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

class MyLayout(View):
    def __init__(self, page: Page, route='/'):
        super().__init__()
        self.page = page
        self.route = route
        if self.route=='/':
            self.page_title = 'Home'
        elif self.route=='/about':
            self.page_title = 'About'
        elif self.route=='/contact':
            self.page_title = 'Contact'

        self.controls = [
            AppHeader(self.page, self.page_title.upper()),
            Row(
                alignment=MainAxisAlignment.START,
                vertical_alignment=CrossAxisAlignment.START,
                controls=[Sidebar(self.page),ContentBody(self.page_title.upper()+' Page')],
            ),
        ]
```

`route`について条件分岐をすることで画面の切り替えができるようにしています。（コンポーネントを条件分岐で切り替えることも可能です。）


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

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

だし分け用に`text`を追加しています。

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

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=icons.LIGHT_MODE_OUTLINED,
            selected_icon =icons.DARK_MODE_OUTLINED,
            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"),
        ]
        self.leading=Icon(icons.TRIP_ORIGIN_ROUNDED)
        self.leading_width=100
        self.title=Text(value=self.page_title, size=32, text_align="center")
        self.center_title=False
        self.toolbar_height=75
        self.bgcolor=colors.SURFACE_VARIANT
        self.actions=[
            Container(
                margin=margin.only(left=50, right=25),
                content=Row(
                    alignment=MainAxisAlignment.SPACE_BETWEEN,
                    controls=[
                        self.toggle_dark_light_icon,
                        ElevatedButton(text="SOMETHING"),
                        PopupMenuButton(
                            items=self.appbar_items
                        ),
                    ],
                ),
            )
        ]

    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.Route`を追加したのでRouteに関する制御が可能になります。（未実装ですが。）

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

class Sidebar(Container):
    def __init__(self, page:Page):
        super().__init__()
        self.page = page
        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=self.tap_nav_icon,
        )
        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()

    def tap_nav_icon(self, e):
        if e.control.selected_index == 0:
            self.page.go('/about')
        elif e.control.selected_index == 1:
            self.page.go('/contact')
        else:
            self.page.go('/')
```

`tap_nav_icon`を実装し、画面切り替えを行えるようにしています。（`Page`を追加したおかげですね。）

# 4. まとめ

今回は画面レイアウトを作成後のステップとしてルーティングを実装しました。
前回のコンポーネントの単純な拡張ですが、だいぶWEBアプリケーションぽくなった気がします。
次回は本文をリッチにしていくなどしていきたいと思います。