🐠

Fletの画面遷移におけるTIPS

2023/10/17に公開

はじめに

FletはPythonでFlutterアプリを構築できるフレームワークです。
ボタンやテキストボックスといったGUIとしての部品だけでなく、異なる画面間を遷移するための部品も揃ってます。
ただ、その取り扱いにはけっこう苦戦したので、その時に得た知見をを共有します。

読者の想定

Fletの公式ドキュメントの「Navigation and routing」を読んで、以下を理解している

  • page.viewsの最後の要素が画面に表示される
    • page.viewsft.Viewオブジェクトのリスト
  • page.go()などでRouteが変更される時にpage.on_route_changeで指定した関数が実行される
  • AppBarの戻るボタンをクリックするとpage.on_view_popで指定した関数が実行される

https://flet.dev/docs/guides/python/navigation-and-routing

環境構成

筆者は下記の構成で動作を確認しています。

  • OS:Windows10
  • Pythonバージョン:3.11.2
  • Fletバージョン:0.4.2

この記事を読んで実現できる画面遷移

ある目的を達成するためには複数のフェーズがあり、各フェーズでのタスクが完了したら次の画面へ遷移していくといった動作を実現します。

例えば、ショッピングサイトで買い物する時に、商品検索画面→カート画面→購入情報入力画面→購入完了画面→商品検索画面に戻るといった感じで画面遷移するイメージです。

この記事では簡単のために、下図のような画面遷移を行う処理を実装することを前提とします。

  • 画面遷移用のボタンを押すと次の画面に遷移する
  • フォームに入力したデータを次の画面に渡す
  • AppBarの戻るボタンを押すと前の画面に遷移する
    • View2画面からView1画面に戻った時、View1画面で入力したデータはそのまま残った状態
  • View2画面からの画面遷移ではTop画面に遷移する
    • Top画面からView2画面には戻れないようにする

実装したプログラムのソースコード

前述した画面遷移を実装したプログラムのソースコードです。

クリックすると展開されます
import flet as ft

class Top(ft.View):
    def __init__(self):
        data = "Top data"
        controls = [
            ft.AppBar(title=ft.Text("Top view"), bgcolor=ft.colors.SURFACE_VARIANT),
            ft.TextField(value=data, on_change=self.changed),
            ft.ElevatedButton("Go to View1", on_click=self.clicked)
        ]
        super().__init__("/", controls=controls)
        self.data = data
        
    def clicked(self, e):
        e.page.go("/view1")
    
    def changed(self, e):
        self.data = e.control.value
        self.update()


class View1(ft.View):
    def __init__(self, _data):
        data = 'View1 data'
        controls = [
            ft.AppBar(title=ft.Text("View1"), bgcolor=ft.colors.SURFACE_VARIANT),
            ft.Text(f'Top\'s data: {_data}'),
            ft.TextField(value=data, on_change=self.changed),
            ft.ElevatedButton("Go to View2", on_click=self.clicked)
        ]
        super().__init__("/view1", controls=controls)
        self.data = data
        
    def clicked(self, e):
        e.page.go("/view2")
    
    def changed(self, e):
        self.data = e.control.value
        self.update()


class View2(ft.View):
    def __init__(self, _data):
        controls = [
            ft.AppBar(title=ft.Text("View2"), bgcolor=ft.colors.SURFACE_VARIANT),
            ft.Text(f'View1\'s data: {_data}'),
            ft.ElevatedButton("Go to Top", on_click=self.clicked)
        ]
        super().__init__("/view2", controls)
        
    def clicked(self, e):
        e.page.go("/")


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

    pop_flag = False

    def route_change(e):
        nonlocal pop_flag

        if pop_flag:
            pop_flag = False
        else:
            if page.route == "/":
                page.views.clear()
                page.views.append(
                    Top()
                )
            elif page.route == "/view1":
                page.views.append(
                    View1(page.views[-1].data)
                )
            elif page.route == "/view2":
                page.views.append(
                    View2(page.views[-1].data)
                )
        
    def view_pop(e):
        nonlocal pop_flag
        pop_flag = True
        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.views.clear()
    page.go("/")


if __name__ == '__main__':
    ft.app(target=main, view=ft.WEB_BROWSER)

各画面を開いている時のpage.routeとpage.viewsの要素数は下表のようになります。

画面 page.route len(page.views)
Top画面 / 1
View1画面 /view1 2
View2画面 /view2 3

画面遷移を実装する際のポイント

画面遷移を実装する際のポイントは以下の3点です。

  • 画面毎にft.Viewを継承したクラスを作成する
  • 画面が持つデータを保持する際はpage.views.clear()を使わない
  • view_pop時にもroute_changeが呼ばれることに気をつける

画面毎にft.Viewを継承したクラスを作成する

画面毎に独自の処理を実行させたり、画面遷移先にデータを渡したりしたいことがあります。そういう時は画面毎にft.Viewを継承したクラスを作成すると良いでしょう。

ft.Viewオブジェクトはdataという任意のデータを保持するための変数を持っているので、画面遷移先にデータを渡すためにこの変数を使うこともできます。しかし、dataに値を格納したり、値を更新するための処理は定義されていません。
そのため、ft.Viewを継承したクラスを作成して、そこに定義してあげるわけです。

もちろん、クラス内のメソッドではなく独自の関数として定義することもできますが、画面数が増えてくると似て非なる関数を別名で作成する必要があったり、どの関数をどの画面で利用するのかといった管理が煩雑になったりしてきます。

実装したプログラムでは、各画面で類似する関数を必要としますが、それぞれのクラス内のメソッドとして定義しているので、名前の重複を気にする必要がありません。

Topクラス
class Top(ft.View):
    def __init__(self):
        # 省略
        
    def clicked(self, e):
        e.page.go("/view1")
    
    def changed(self, e):
        self.data = e.control.value
        self.update()
View1クラス
class View1(ft.View):
    def __init__(self, _data):
        # 省略
        
    def clicked(self, e):
        e.page.go("/view2")
    
    def changed(self, e):
        self.data = e.control.value
        self.update()
View2クラス
class View2(ft.View):
    def __init__(self, _data):
        # 省略
        
    def clicked(self, e):
        e.page.go("/")

画面が持つデータを保持する際はpage.views.clear()を使わない

公式ドキュメントのRoute Exampleでは、Routeが変わる度にpage.views.clear()を実行し、そのRouteまでのViewを追加し直すことで画面遷移を実現しています。

Route Exampleより一部抜粋
def route_change(route):
    page.views.clear()
    page.views.append(
        ft.View(
            "/",
            [
                ft.AppBar(title=ft.Text("Flet app"), bgcolor=ft.colors.SURFACE_VARIANT),
                ft.ElevatedButton("Visit Store", on_click=lambda _: page.go("/store")),
            ],
        )
    )
    if page.route == "/store":
        page.views.append(
            ft.View(
                "/store",
                [
                    ft.AppBar(title=ft.Text("Store"), bgcolor=ft.colors.SURFACE_VARIANT),
                    ft.ElevatedButton("Go Home", on_click=lambda _: page.go("/")),
                ],
            )
        )
    page.update()

https://flet.dev/docs/guides/python/navigation-and-routing#building-views-on-route-change

しかし、新しい画面遷移先のViewだけでなく、元々いた画面のViewも追加し直すのは無駄がある上に、AppBarの戻るボタンで元のページに戻っても表示されるViewは元のものとは別物であり、元々いた画面での入力情報は破棄されてしまいます。
画面が持つデータを保持する際はpage.views.clear()は使わない方が良いでしょう。

実装したプログラムでは、Top画面に遷移した時だけpage.views.clear()が実行されるようになっています。

route_change関数
def route_change(e):
    nonlocal pop_flag

    if pop_flag:
        pop_flag = False
    else:
        if page.route == "/":
	    page.views.clear()          # <- page.routeが"/"の時だけ実行
            page.views.append(
                Top()
            )
        elif page.route == "/view1":
            page.views.append(
                View1(page.views[-1].data)
            )
        elif page.route == "/view2":
            page.views.append(
                View2(page.views[-1].data)
	    )

view_pop時にもroute_changeが呼ばれることに気をつける

これには一番ハマりました。
前述したように、画面が持つデータを保持したかったのでTop画面に遷移した時だけpage.views.clear()が実行されるように変更したのですが、AppBarの戻るボタンを押した時に実行されるview_pop関数内のpage.go()で一つ前の画面に遷移する際にもroute_changeが呼ばれるため、余分に前の画面を追加してしまうようになりました。
(Route Exampleでは毎度一からViewを作成し直してるためこの事象は発生しない)

操作 処理 page.route len(page.views)
-(初期状態) - / 1
Top画面の画面遷移ボタン page.go('/view1') /view1 1→2
View1画面のAppBarの戻るボタン page.views.pop()
top_view=page.views[-1]
page.go(top_view.route)
/ 2→1→2

これを回避するために、page.go()ではなくpage.update()を実行するように変更してみました。

view_pop関数(ダメな例)
def view_pop(e):
    page.views.pop()
    page.update()

AppBarの戻るボタンで戻る際には一見問題ないのですが、page.routeAppBarの戻るボタンを押す前の画面の状態です。そのため、戻った画面で再び次の画面に遷移するボタンを押したとしても、Routeが変わっていないためroute_changeが実行されません。

操作 処理 page.route on_route_change
で指定した関数
-(初期状態) - / -
Top画面の画面遷移ボタン page.go('/view1') /view1 実行される
View1画面のAppBarの戻るボタン page.views.pop()
page.update()
/view1 実行されない
Top画面の画面遷移ボタン page.go('/view1') /view1 実行されない

上記の結果から、view_pop関数内のpage.go()で画面表示とRouteを前の画面に変更しつつ、route_change関数で余分な画面が追加されないよう配慮する必要があることが分かりました。

実装したプログラムでは、view_popが呼ばれた時のフラグをpop_flagで管理し、Trueの時は画面の追加処理は実行しないように分岐するようにしました。

route_change関数
def route_change(e):
    nonlocal pop_flag                 # <- 上位のmain関数スコープの変数

    if pop_flag:
        pop_flag = False              # <- フラグがTrueの時はFalseにするだけ
    else:
        # 省略
view_pop関数
def view_pop(e):
    nonlocal pop_flag                 # <- 上位のmain関数スコープの変数
    pop_flag = True                   # <- フラグをTrueにする
    page.views.pop()
    top_view = page.views[-1]
    page.go(top_view.route)

おわりに

これらのポイントを押さえておけば、Fletアプリ内での画面遷移についてはそれなりに動くと思います。
しかし、ブラウザの進む/戻るボタンやアドレスバーへの直接入力による画面遷移には対応していないため、本来であればFletアプリ外からRoute変更された場合の挙動も考慮する必要があります。
現時点で思いつくのは、Fletアプリ内のRoute変更にフラグを立て、それ以外のRoute変更は許容しないといったところでしょうか。

Discussion