1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FletのViewRouterを作ってみる

Posted at

はじめに

フロントエンドといえば JavaScript (または TypeScript) で作るものという図式が完全に出来上がってしまっていますが、どうにも Javascript が好きになれないもので、Python で実戦的にフロントエンド書けるフレームワークはないのかとを探していました。

調べて一番出てくるのは Streamlit ですが、Python コードをサーバ側で処理するものなので、いわゆるフロントエンドとは別物ですよね。

そんな中、Flet というフレームワークが Static website としてホスティングすることができる (WebAssembly としてブラウザ上で Python code を動かせる!!) ということで「これは面白そうだ」と触ってみました。

画面を宣言的に書きたい

触っていく中でも困ったのが router のようなものが存在しないことでした。
公式ドキュメントの Navigation and routing の例では以下のような実装が示されています。

import flet as ft

def main(page: ft.Page):
    page.title = "Routes Example"

    def route_change(route):
        page.views.clear()
        page.views.append(
            ft.View(
                "/",
                [
                    ...(中略)
                ],
            )
        )
        if page.route == "/store":
            page.views.append(
                ft.View(
                    "/store",
                    [
                        ...(中略)
                    ],
                )
            )
        page.update()

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

    page.on_route_change = route_change
    page.on_view_pop = view_pop
    page.go(page.route)


ft.app(main, view=ft.AppView.WEB_BROWSER)

main 関数の route_change 関数が route 変更イベントに対して条件分岐でどのような View のスタックを作成するか書いて行っています。

この例だと / の View は必ずスタックされ、route が /store のときには加えてもう一層の View がスタックされています。

この手続き的な書き方続けるのはだいぶ厳しいので、自前の ViewRouter class を作成して宣言的に View を書けるようにしてみました。

Router を使用したコード

以下のような全体像になりました

|-- core
|   `-- routed_view.py
|-- main.py
|-- requirements.txt
|-- router
|   |-- __init__.py
|   `-- router.py
`-- views
    |-- users
    |   `-- index.py
    `-- index.py

main関数

entrypoint となる main.py のファイルはこのようになりました。

import flet as ft

import router


def main(page: ft.Page):
    page.title = "Example"

    r = router.ViewRouter(page)

    def view_pop(view):
        page.views.pop()
        if len(page.views) > 1:
            page.update()
        else:
            page.go("/")

    page.on_route_change = r.route_change
    page.on_view_pop = view_pop

    page.go(page.route)


ft.app(target=main)

on_route_change では ViewRouter のメソッドを呼び出しています

ViewRouter

router/router.py で ViewRouter を定義しています。

import time

import flet as ft

from core.routed_view import RoutedView
from views.user.index import User
from views.index import Home

VIEWS: list[type[RoutedView]] = [
    Home,
    User,
]


class ViewRouter:
    page: ft.Page
    routes: dict[str, RoutedView] = {}

    def __init__(self, page: ft.Page):
        self.page = page
        for view in VIEWS:
            self.routes[view.get_path()] = view

    def route_change(self, route: ft.RouteChangeEvent):
        self.page.views.clear()

        head_view = None

        tr = ft.TemplateRoute(route.route)
        for path, view in self.routes.items():
            if matched := tr.match(path):
                head_view = view
                break

        if not matched:
            self.page.views.append(
                ft.View(
                    "/not_found",
                    [ft.Text(value="NOT FOUND. Redirect to Home in 3 seconds")],
                )
            )
            self.page.update()
            time.sleep(3)
            self.page.go(Home.get_path())
            return

        stack = head_view.gen_view_stack(self.page)

        while len(stack) > 0:
            self.page.views.append(stack.pop())

        self.page.update()

VIEWS に Application に追加する RoutedView を継承した class を登録して、これをもとに path に対する RoutedView の対応を持っておきます。

route が変更されると route と match する RoutedView を特定し、最前面に表示される head_view とします。

RoutedView は gen_view_stack メソッドを持っていて、これにより head_view から一つづつ背後にあるべき View に遡る形でページに追加する全ての View を取得します。
これを list の逆順に全て page に追加し、ページの更新を更新します。

RoutedView

RoutedView は以下のような抽象クラスとなっていて、これを各 View の内容を示す class が継承しています。

core/routed_view.py

from abc import ABC, abstractmethod
from typing import Optional

import flet as ft
from flet_core import Control


class RoutedView(ABC, ft.View):
    path: str
    parent: Optional["RoutedView"]

    @classmethod
    def get_path(cls) -> str:
        return cls.path

    @classmethod
    def generate(cls, page) -> ft.View:
        contents = cls.get_contents(page)
        view = cls(cls.path, contents)
        return view

    @classmethod
    def gen_view_stack(cls, page, stack=[]) -> list["RoutedView"]:
        stack.append(cls.generate(page))
        if cls.parent is None:
            return stack
        return cls.parent.gen_view_stack(page, stack)

    @classmethod
    @abstractmethod
    def get_contents(cls, page) -> list[Control]:
        raise NotImplementedError()

RoutedView はディレクトリのような階層構造を持っていて、例えば /users の path を持つ Users は、/を表す Homeparent としています。Home は一番先頭の階層となるので parent を持ちません

gen_view_stack は各クラスで定義された get_contents を呼び出すことで View を生成し、その後 parent で指定されている class の gen_view_stack を呼び出すことを再帰的に実行して先頭の階層までを View をスタックしたものを返します。

views/index.py

import flet as ft
from flet_core.control import Control

from core.routed_view import RoutedView


class Home(RoutedView):
    path = "/"
    parent = None

    @classmethod
    def get_contents(self, page) -> list[Control]:
        def open_users_screen(e):
            page.go("/users")

        contents = [
            ft.AppBar(title=ft.Text("Flet app"), bgcolor=ft.colors.SURFACE_VARIANT),
            ft.ElevatedButton("Go to users", on_click=open_users_screen),
        ]

        return contents

views/users/index.py

import flet as ft
from flet_core.control import Control
import requests

from core.routed_view import RoutedView
from views.index import Home


class Users(RoutedView):
    path = "/users"
    parent = Home

    @classmethod
    def get_contents(cls, page) -> list[Control]:
        contents = [
            ft.AppBar(title=ft.Text("ユーザ管理"), bgcolor=ft.colors.SURFACE_VARIANT),
            ...(中略)
        ]

        return contents

おわりに

ViewRouter が個別のアプリケーションのglobal変数読んでいたり、気持ち悪いところが残っているので、今後改善していこうと思います。

(そもそも flet 自体に Router の機能存在していてほしかったんですが、需要無いんでしょうかね...?)

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?