Closed15

Pythonでクロスプラットフォームなアプリが作れる「Flet」を試す

kun432kun432

https://flet.dev/docs/

はじめに

Fletとは?

Fletは、フロントエンド開発の経験がなくても、PythonでWeb、デスクトップ、モバイルアプリケーションを構築できるフレームワークである。

Flet controlsはGoogleのFlutterをベースとしており、これを使用してプログラムのUIを構築できる。Fletは、Flutterウィジェットを単にラップするだけにとどまらない。より小さなウィジェットを組み合わせ、複雑な部分を簡素化し、UIのベストプラクティスを実装し、適切なデフォルト値を設定することで、独自のタッチを加えている。これにより、ユーザー側でデザインに追加の労力をかけることなく、スタイリッシュで洗練されたアプリケーションを実現できる。

サンプルの「カウンター」アプリを動かしてみる。ローカルのMacで。

仮想環境を用意しておいて、パッケージインストール

$ pip install flet
$ flet --version
0.24.1

counter.pyをコピペ

counter.py
import flet as ft

def main(page: ft.Page):
    page.title = "Flet counter example"
    page.vertical_alignment = ft.MainAxisAlignment.CENTER

    txt_number = ft.TextField(value="0", text_align=ft.TextAlign.RIGHT, width=100)

    def minus_click(e):
        txt_number.value = str(int(txt_number.value) - 1)
        page.update()

    def plus_click(e):
        txt_number.value = str(int(txt_number.value) + 1)
        page.update()

    page.add(
        ft.Row(
            [
                ft.IconButton(ft.icons.REMOVE, on_click=minus_click),
                txt_number,
                ft.IconButton(ft.icons.ADD, on_click=plus_click),
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )
    )

ft.app(main)

flet runで実行

$ flet run counter.py

MacのネイティブなOSウインドウで上がってきた。

ボタンも機能する。

Webアプリとして起動する

$ flet run --web counter.py

ブラウザで上がってきて、ちゃんと動いている。おー、クロスプラットフォームだ。

flet runのヘルプを見ると、iOSやAndroidでもできるっぽい。実際には環境構築が必要な気がするけど、まあそれはおいおい。

$ flet run --help
usage: flet run [-h] [-v] [-p PORT] [--host HOST] [--name APP_NAME] [-m] [-d] [-r] [-n] [-w] [--ios] [--android]
                [-a ASSETS_DIR] [--ignore-dirs IGNORE_DIRS]
                [script]

Run Flet app.

positional arguments:
  script                path to a Python script

options:
  -h, --help            show this help message and exit
  -v, --verbose         -v for detailed output and -vv for more detailed
  -p PORT, --port PORT  custom TCP port to run Flet app on
  --host HOST           host to run Flet web app on. Use "*" to listen on all IPs.
  --name APP_NAME       app name to distinguish it from other on the same port
  -m, --module          treat the script as a python module path as opposed to a file path
  -d, --directory       watch script directory
  -r, --recursive       watch script directory and all sub-directories recursively
  -n, --hidden          application window is hidden on startup
  -w, --web             open app in a web browser
  --ios                 open app on iOS device
  --android             open app on Android device
  -a ASSETS_DIR, --assets ASSETS_DIR
                        path to assets directory
  --ignore-dirs IGNORE_DIRS
                        directories to ignore during watch. If more than one, separate with a comma.

kun432kun432

Getting Started

https://flet.dev/docs/getting-started/

Getting Startedに従って進めていく。最初のインストールはすでに終わってるので、そのまま進める

Fletアプリの新規作成

https://flet.dev/docs/getting-started/create-flet-app

flet create プロジェクト名でプロジェクトを作成。

$ flet create my_flet_app

Done!

Run the app:

flet run my_flet_app

プロジェクトディレクトリの構成

$ tree -a my_flet_app/
my_flet_app/
├── .gitignore
├── README.md
├── assets
│   └── icon.png
├── main.py
└── requirements.txt

2 directories, 5 files

作成されているmain.pyはこんな感じ

import flet as ft


def main(page: ft.Page):
    page.add(ft.SafeArea(ft.Text("Hello, Flet!")))


ft.app(main)

main()の中に、ページやウインドウに追加するUIコンポーネント(Flet controlsというらしい)を並べて、ft.app()でアプリケーションとして初期化・実行するということらしい。

なお、先にディレクトリを作成しておいて、その中にプロジェクトを作ることもできる。

$ mkdir my_flet_app2 && cd my_flet_app2
$ flet create .
Done!

Run the app:

flet run

$ cd ..

また、テンプレートから作成することもできる。

$ flet create --template counter my_flet_counter
Done!

Run the app:

flet run my_flet_counter

$ tree -a my_flet_counter/
my_flet_counter/
├── .gitattributes
├── .gitignore
├── README.md
├── assets
│   └── icon.png
├── main.py
└── requirements.txt

2 directories, 6 files
my_flet_counter/main.py
import flet as ft


def main(page: ft.Page):
    page.title = "Flet counter example"
    page.vertical_alignment = ft.MainAxisAlignment.CENTER

    txt_number = ft.TextField(value="0", text_align=ft.TextAlign.RIGHT, width=100)

    def minus_click(e):
        txt_number.value = str(int(txt_number.value) - 1)
        page.update()

    def plus_click(e):
        txt_number.value = str(int(txt_number.value) + 1)
        page.update()

    page.add(
        ft.Row(
            [
                ft.IconButton(ft.icons.REMOVE, on_click=minus_click),
                txt_number,
                ft.IconButton(ft.icons.ADD, on_click=plus_click),
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )
    )


ft.app(main)

flet createのヘルプを見ると、counter/minimalの2つが用意されているように見えるが、minimalで試してみたら最初の"Hello Flet"だったので、デフォルトらしい。

$ flet create --help
usage: flet create [-h] [-v] [--project-name PROJECT_NAME] [--description DESCRIPTION] [--template {minimal,counter}]
                   output_directory

Create a new Flet app from a template.

positional arguments:
  output_directory      project output directory

options:
  -h, --help            show this help message and exit
  -v, --verbose         -v for detailed output and -vv for more detailed
  --project-name PROJECT_NAME
                        project name for the new Flet app
  --description DESCRIPTION
                        the description to use for the new Flet project
  --template {minimal,counter}
                        template to use for new Flet project
kun432kun432

Fletアプリの実行

https://flet.dev/docs/getting-started/running-app/

main.pyがあるディレクトリで

$ flet run

もしくは、main.pyを含むディレクトリ≒プロジェクトのディレクトリがあるディレクトリで

$ flet run my_flet_app

Webアプリとして立ち上げる場合は--webをつけるとブラウザが自動で立ち上がってアクセスできる。

$ flet run --web my_flet_app

ポートを指定しない場合はどうやらランダムになる様子。任意のポートを指定することもできる

$ flet run --web --port 11111 my_flet_app

-dでホットリロードも可能。-rをつけるとサブディレクトリがある場合にそこも再帰的に変更を見るようになる。

$ flet run -d -r my_flet_app

main.pyを書き換えると反映される。

kun432kun432

Flet controls

https://flet.dev/docs/getting-started/flet-controls

FletのUIは、controls(通称: ウィジェット)で構成される。controlsはPageまたは他のcontrolsの中に配置する。Pageが最上位のcontrolになり、controlsをネストしていくとPageがルートになるツリー構造として、UIが構成される。

controlsはPythonのクラスになっているので、以下のようにインスタンスを作成することで利用できる。そしてPageにcontrolsを追加、Pageを更新することで反映される。

import flet as ft

def main(page: ft.Page):
    # controlを初期化
    t = ft.Text(value="Hello, world!", color="green", size=30)
    # pageに追加
    page.controls.append(t)
    # pageを更新
    page.update()

ft.app(main)
$ flet run -d -r my_flet_app

page.update()を使うとcontrolのプロパティおよびUIを更新できる。

import flet as ft
import time

def main(page: ft.Page):
  t = ft.Text(size=30)
  page.add(t) # page.controls.append(t) および page.update() のショートカット

  for i in range(100):
      t.value = f"Step {i}"
      page.update()
      time.sleep(1)

ft.app(main)

Pageのように、他のcontrolを含むことができる「コンテナ」としてのcontrolもある。

rowだと横に並べる

import flet as ft

def main(page: ft.Page):

  page.add(
      ft.Row(controls=[
        ft.TextField(label="Your name"),
        ft.ElevatedButton(text="Say my name!")
      ])
  )

ft.app(main)

Columnだと縦に並べる、といった感じ。

import flet as ft

def main(page: ft.Page):

  page.add(
      ft.Column(controls=[
        ft.TextField(label="Your name"),
        ft.ElevatedButton(text="Say my name!")
      ])
  )

ft.app(main)

page.update()は最後に実行されてから変更があった部分だけを更新するようになっているので、controlをまとめて更新してからpage.update()一発で変更できる。

import flet as ft
import time

def main(page: ft.Page):

  for i in range(100):
    page.controls.append(ft.Text(f"Line {i}", size=20))
    if i > 4:
        page.controls.pop(0)

    page.update()
    time.sleep(0.5)


ft.app(main)

ボタンなどのcontrolにはイベントハンドラを設定できる。

import flet as ft
import time

def main(page: ft.Page):

    def button_clicked(e):
        page.add(ft.Text("クリックされました!", size=30))

    page.add(ft.ElevatedButton(text="クリックしてね", on_click=button_clicked))

ft.app(main)

これを使ってシンプルなTo-Doアプリの例が載っている。

import flet as ft

def main(page):
    def add_clicked(e):
        page.add(ft.Checkbox(label=new_task.value))
        new_task.value = ""
        new_task.focus()
        page.update()

    new_task = ft.TextField(hint_text="ToDoを入力", width=300)
    page.add(ft.Row([new_task,ft.ElevatedButton("追加", on_click=add_clicked)]))

ft.app(main)

こんなことが書いてある、なるほど、今の自分にはこのほうがわかりやすいかもしれない。

Fletは、ステートフルコントロールを使用して「手動」でアプリケーションのUIを構築し、コントロールのプロパティを更新することでそれを変更する、命令型UIモデルを実装している。Flutterは、アプリケーションデータの変更時にUIが自動的に再構築される宣言型モデルを実装している。最新のフロントエンドアプリケーションにおけるアプリケーションの状態管理は、本質的に複雑な作業であり、Fletの「旧式」のアプローチは、フロントエンドの経験のないプログラマーにとってはより魅力的である可能性がある。

visible/disabledプロパティ

controlの可視性や操作可否をコントロールすることができるプロパティ。なお、子要素がある場合はそれも含めて制御される。

visibleを使うと表示・非表示を切り替えれる。デフォルトはTrue(表示する)。

import flet as ft

def main(page: ft.Page):

    def toggle_visible(e):
        if t.visible == True:
            t.visible = False
            button.text = "visible!"
        else:
            t.visible = True
            button.text = "invisible!"
        page.update()

    button = ft.ElevatedButton("invisible!", on_click=toggle_visible)
    t = ft.Text(value="Hello, world!", color="green", size=30)

    page.add(ft.Column([button, t]))
    page.update()

ft.app(main)

disabledを使うとデータ入力に関するコントロールの操作、例えば入力・変更等の可否を切り替えれる。デフォルトはFalse(コントロール可能)。

import flet as ft

def main(page: ft.Page):

    def toggle_disabled(e):
        if t.disabled == True:
            t.disabled = False
            button.text = "disable!"
        else:
            t.disabled = True
            button.text = "enable!"
        page.update()

    button = ft.ElevatedButton("disable", on_click=toggle_disabled)
    t = ft.TextField(hint_text="enter something...", width=300)

    page.add(ft.Column([button, t]))
    page.update()

ft.app(main)

あとはボタン・テキストボックス・チェックボックス・ドロップダウンなどを使ったイベントハンドラのサンプルコートが並んでる。

ボタンをクリックしたら増減するカウンター。

import flet as ft

def main(page: ft.Page):
    page.title = "カウンターのサンプル"
    page.vertical_alignment = ft.MainAxisAlignment.CENTER

    txt_number = ft.TextField(value="0", text_align="right", width=100)

    def minus_click(e):
        txt_number.value = str(int(txt_number.value) - 1)
        page.update()

    def plus_click(e):
        txt_number.value = str(int(txt_number.value) + 1)
        page.update()

    page.add(
        ft.Row(
            [
                ft.IconButton(ft.icons.REMOVE, on_click=minus_click),
                txt_number,
                ft.IconButton(ft.icons.ADD, on_click=plus_click),
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )
    )

ft.app(main)

名前を入力したら挨拶が表示される。

import flet as ft

def main(page):
    def btn_click(e):
        if not txt_name.value:
            txt_name.error_text = "名前を入力してください"
            page.update()
        else:
            name = txt_name.value
            page.clean()
            page.add(ft.Text(f"こんにちは、{name} さん!"))

    txt_name = ft.TextField(label="あなたの名前")

    page.add(txt_name, ft.ElevatedButton("挨拶する", on_click=btn_click))

ft.app(main)

フォーカスが当たるとプレースホルダーの文字がラベルっぽくなるの良いね

何も入力せずにボタンクリックするとこうなる

チェックボックスに連動して、チェックボックスの状態をテキストで出力

import flet as ft


def main(page):
    def checkbox_changed(e):
        output_text.value = (
            f"スキーを習得した? :  {todo_check.value}."
        )
        page.update()

    output_text = ft.Text()
    todo_check = ft.Checkbox(label="ToDo: スキーの習得", value=False, on_change=checkbox_changed)
    page.add(todo_check, output_text)

ft.app(main)

ドロップダウンリストの選択値を取得して表示

import flet as ft


def main(page: ft.Page):
    def button_clicked(e):
        output_text.value = f"リストから選択された色:  {color_dropdown.value}"
        page.update()

    output_text = ft.Text()
    submit_btn = ft.ElevatedButton(text="送信", on_click=button_clicked)
    color_dropdown = ft.Dropdown(
        width=100,
        options=[
            ft.dropdown.Option("赤"),
            ft.dropdown.Option("緑"),
            ft.dropdown.Option("青"),
        ],
    )
    page.add(color_dropdown, submit_btn, output_text)

ft.app(main)

kun432kun432

カスタムなcontrols

Fletにはビルドインのcontrolが多数用意されているが、自分でカスタムなcontrolを作ることもできる。カスタムなcotnrolは、Pythonで独自にスタリングしたり、既存のcontrolsを組み合わせることができる。

Styled controls

特定のスタイルを定義したcontrolを作成できる。

例えば、ft.ElevatedButtonを継承したクラスを作って、コンストラクタでカスタムなプロパティやイベントなどを受けれるようにする。super().__init__()で親クラスのプロパティやメソッドを継承する必要がある。

import flet as ft

class MyButton(ft.ElevatedButton):
    def __init__(self, text):
        super().__init__()
        self.bgcolor = ft.colors.RED_500
        self.color = ft.colors.WHITE
        self.text = text

def main(page: ft.Page):
    page.add(MyButton(text="OK"), MyButton(text="Cancel"))

ft.app(main)

イベントを受け取る場合はプロパティと同じように、イベントハンドラを引数として渡せるようにコンストラクタを定義する。

import flet as ft

class MyButton(ft.ElevatedButton):
    def __init__(self, text, on_click):
        super().__init__()
        self.bgcolor = ft.colors.RED_500
        self.color = ft.colors.WHITE
        self.text = text
        self.on_click = on_click

def main(page: ft.Page):

    def ok_clicked(e):
        t.value = "OK clicked"
        page.update()

    def cancel_clicked(e):
        t.value = "Cancel clicked"
        page.update()

    t = ft.Text()
    page.add(
        MyButton(text="OK", on_click=ok_clicked),
        MyButton(text="Cancel", on_click=cancel_clicked),
        t,
    )

ft.app(main)

Composite controls

ColumnRowStackといったレイアウトに関するcontrolsもカスタム化できる。

class Task(ft.Row):
    """
    タスクcontrolクラス
    """
    def __init__(self, text):
        super().__init__()
        # 表示時・編集時のcontrolsそのものを定義
        self.text_view = ft.Text(text)
        self.text_edit = ft.TextField(text, visible=False)
        # 編集時・保存時のアイコン表示の定義
        self.edit_button = ft.IconButton(icon=ft.icons.EDIT, on_click=self.edit)
        self.save_button = ft.IconButton(
            visible=False, icon=ft.icons.SAVE, on_click=self.save
        )
        self.controls = [
            ft.Checkbox(),
            self.text_view,
            self.text_edit,
            self.edit_button,
            self.save_button,
        ]

    def edit(self, e):
        """
        編集時は、保存ボタンを表示し、テキストボックスを表示
        """
        self.edit_button.visible = False
        self.save_button.visible = True
        self.text_view.visible = False
        self.text_edit.visible = True
        self.update()

    def save(self, e):
        """
        保存時は、編集ボタンを表示し、テキストを表示
        """
        self.edit_button.visible = True
        self.save_button.visible = False
        self.text_view.visible = True
        self.text_edit.visible = False
        self.text_view.value = self.text_edit.value
        self.update()

def main(page: ft.Page):

    page.add(
        Task(text="洗濯する"),
        Task(text="夕飯作る"),
    )


ft.app(main)

ライフサイクルメソッド

controlにはライフサイクル"フック"メソッドというものがある。これはcontrolがpageに追加されたり、更新されたり、の際に呼び出されるものらしく、これをカスタムcontrolでは(必要ならば)上書きできたりする。

  • build(): controlが作成されて、self.pageにアサインされた時に実行される
  • did_mount(): controlがpageにアサインされて一時的なuidを割り当てらたときに実行される
  • will_umount(): cotnrolがpageから削除される前に呼び出される
  • before_update(): controlが更新される前に呼び出される

ちょっとここは今の自分には難しすぎたので、おいおい。

Isolated controls

カスタムなcontrolにはすべてis_isolatedプロパティがある。is_isolatedTrueの場合、そのcontrolは外側のレイアウトから分離されるので、親のcontrolでupdate()が行われても、子control自体は更新はされるが、子controlへの変更は更新されない、らしい?デフォルトはFalse

なんとなく雰囲気はわかるんだけど、書いてある例がピンとこなかったので、多分まだ理解できてない。

kun432kun432

アダプティブアプリ

Fletは単一のコードベースでプラットフォームごとに異なる複数の見た目を提供する「アダプティブアプリ」に対応している。

「レスポンシブ」と何が違うのかな?と思ったんだけど、スマホとPCでの操作インタフェースの違いとかプラットフォーム固有機能なども含めて、ということらしい。このあたりはFlutterがベースになっているので自ずとそうなるのだろうと思う。

https://zenn.dev/joo_hashi/scraps/fd5cdc57bfef12

紹介されているサンプルは以下のようなものになっていて、page.adaptive = Trueってのがアダプティブを有効にする設定。多分iOS/Androidアプリとして試したら違いが明確になるんだと思う。一応Webでも多少は違ったけど。

import flet as ft


def main(page):
    # アダプティブ有効
    page.adaptive = True

    page.appbar = ft.AppBar(
        leading=ft.TextButton("New", style=ft.ButtonStyle(padding=0)),
        title=ft.Text("Adaptive AppBar"),
        actions=[
            ft.IconButton(ft.cupertino_icons.ADD, style=ft.ButtonStyle(padding=0))
        ],
        bgcolor=ft.colors.with_opacity(0.04, ft.cupertino_colors.SYSTEM_BACKGROUND),
    )

    page.navigation_bar = ft.NavigationBar(
        destinations=[
            ft.NavigationBarDestination(icon=ft.icons.EXPLORE, label="Explore"),
            ft.NavigationBarDestination(icon=ft.icons.COMMUTE, label="Commute"),
            ft.NavigationBarDestination(
                icon=ft.icons.BOOKMARK_BORDER,
                selected_icon=ft.icons.BOOKMARK,
                label="Bookmark",
            ),
        ],
        border=ft.Border(
            top=ft.BorderSide(color=ft.cupertino_colors.SYSTEM_GREY2, width=0)
        ),
    )

    page.add(
        ft.SafeArea(
            ft.Column(
                [
                    ft.Checkbox(value=False, label="Dark Mode"),
                    ft.Text("First field:"),
                    ft.TextField(keyboard_type=ft.KeyboardType.TEXT),
                    ft.Text("Second field:"),
                    ft.TextField(keyboard_type=ft.KeyboardType.TEXT),
                    ft.Switch(label="A switch"),
                    ft.FilledButton(content=ft.Text("Adaptive button")),
                    ft.Text("Text line 1"),
                    ft.Text("Text line 2"),
                    ft.Text("Text line 3"),
                ]
            )
        )
    )


ft.app(main)

カスタムなcontrolも自動的にアダプティブになるらしいけど、細かく設定したりってのもできるみたい。

kun432kun432

ナビゲーションとルーティング

https://flet.dev/docs/getting-started/navigation-and-routing

ページ遷移とURLルーティングについて。

ページルーティング

page.routeプロパティでルートを取得できる。

import flet as ft

def main(page: ft.Page):
    page.add(ft.Text(f"初期のルート: {page.route}"))

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

ft.AppViewが突然でてきたのだけど、どうもビューをブラウザ固定にするものみたい。

なので、以下のようにWebアプリとして起動する必要がある

$ flet run --web -d -r my_flet_app

で、ブラウザを開くとルートは/になっている

別ウインドウ・別タブで開いて、URLに/testを追加すると、初期ルートが変わっているのがわかる。なお、ドキュメントにある通り/#/testとしても何も変わらなかった。

一度開いたブラウザでURLを変更しても変わらない。「初期」っていうのはこういうことなんだと思う。

で、URLが変更された(URLが直接入力されたとか、ブラウザ側で戻る・進むなど)が行われると、page.on_route_changeイベントが発生するので、これを拾うようにする。

import flet as ft

def main(page: ft.Page):
    page.add(ft.Text(f"初期のルート: {page.route}"))

    def route_change(e: ft.RouteChangeEvent):
        page.add(ft.Text(f"新しいルート: {e.route}"))

    page.on_route_change = route_change

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

で、以下の状態から

URLを変更するとこうなる。

コード側でルートを変更する場合にはpage.routeプロパティを使う

import flet as ft

def main(page: ft.Page):
    page.add(ft.Text(f"初期のルート: {page.route}"))

    def route_change(e: ft.RouteChangeEvent):
        page.add(ft.Text(f"新しいルート: {e.route}"))

    def go_store(e):
        page.route = "/store"
        page.update()

    page.on_route_change = route_change
    page.add(ft.ElevatedButton("ストアに移動", on_click=go_store))

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

この状態から「ストアに移動」をクリック

/storeにルートが変わった

ブラウザで戻る・進むを繰り返すと、ルートが変わる。

なるほど、遷移の履歴が管理されるのね。

ページビュー

Fletにおいて、pageは単一のページではなく、複数のviewを重ねたコンテナになっている。


refered from https://flet.dev/docs/getting-started/navigation-and-routing

pageには、page.viewsプロパティがあり、これが遷移の履歴となる。page.viewsはリストになっていて、新しく移動するたびにレイヤーとしてviewが上に重なっていくイメージ。最後に追加されたviewが今見ているviewになる。

画面遷移をシミュレートするには、

  • page.routeを変更して、あたらしいviewpage.viewリストの最後に追加する
  • ルートを1つ「前」のページに変更する場合は、page.viewの最後のviewをpopして、page.on_view_popイベントハンドラでルートを「1つ前」のものに変更する

ということになる。

サンプル。

import flet as ft

def main(page: ft.Page):
    page.title = "ルーティングのサンプル"

    def route_change(route):
        page.views.clear()
        page.views.append(
            ft.View(
                "/",
                [
                    ft.AppBar(title=ft.Text("アプリのホーム"), bgcolor=ft.colors.SURFACE_VARIANT),
                    ft.ElevatedButton("ストアに移動", on_click=lambda _: page.go("/store")),
                ],
            )
        )
        if page.route == "/store":
            page.views.append(
                ft.View(
                    "/store",
                    [
                        ft.AppBar(title=ft.Text("ストアのページ"), bgcolor=ft.colors.SURFACE_VARIANT),
                        ft.ElevatedButton("ホームに戻る", on_click=lambda _: page.go("/")),
                    ],
                )
            )

        # デバッグ用に`page.views`を出力
        print(f"views: {page.views}")
        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)

ブラウザでルートにアクセスする

page.viewsはこうなる

views: [View(route='/')]

「ストア」に移動するとこうなる。

views: [View(route='/'), View(route='/store')]

ナビゲーションから戻ってみる。

views: [View(route='/')]

想定通りの動作になっている。

再度ストアのページに移動

このときpage.viewsはこうなっている

views: [View(route='/'), View(route='/store')]

そこからブラウザで「戻る」

views: [View(route='/')]

次に「ホームに移動」で戻るパターン

views: [View(route='/')]

うまくできているように思える。

見様見真似で階層を深くしてみた。ただしこれはすべてが想定通りに動くというわけではない。実際に試してみるとわかるけど、ドキュメントにある上の例は2ページしかないからうまくいく例だということがわかる。

import flet as ft

def main(page: ft.Page):
    page.title = "ルーティングのサンプル"

    def route_change(route):
        if page.route == "/":
            page.views.clear()
            page.views.append(
                ft.View(
                    "/",
                    [
                        ft.AppBar(title=ft.Text("アプリのホーム"), bgcolor=ft.colors.SURFACE_VARIANT),
                        ft.ElevatedButton("ストアに移動", on_click=lambda _: page.go("/store")),
                    ],
                )
            )
        if page.route == "/store":
            page.views.append(
                ft.View(
                    "/store",
                    [
                        ft.AppBar(title=ft.Text("ストアのページ"), bgcolor=ft.colors.SURFACE_VARIANT),
                        ft.ElevatedButton("製品に移動", on_click=lambda _: page.go("/store/product")),
                    ],
                )
            )
        if page.route == "/store/product":
            page.views.append(
                ft.View(
                    "/store/product",
                    [
                        ft.AppBar(title=ft.Text("製品のページ"), bgcolor=ft.colors.SURFACE_VARIANT),
                        ft.ElevatedButton("ストアに戻る", on_click=lambda _: page.go("/store")),
                    ],
                )
            )

        print(f"views: {page.views}")
        page.update()

    def view_pop(view):
        # 現在のビューを削除
        page.views.pop()
        # 前のビューを取得
        previous_view = page.views[-1]
        # 移動後にビューが追加されて、同じビューが2つある状態になるため、更に削除
        page.views.pop()
        page.go(previous_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)

page.views.clear()とかpage.go(route)の使い方とか、インタフェースの作り方とかも考えた上で、作らないといけないんだなと感じる。自分はこのあたりの知見がないので、今の時点ではちょっと難しい。

ルーティングテンプレート

TemplateRouteを使うとパスパラメータの設定ができる。公式のサンプルからon_route_changeヲ使ってルート変更イベントを拾うように変えてみた。

import flet as ft

def main(page: ft.Page):
    page.title = "ルーティングテンプレートのサンプル"

    page.add(ft.Text(f"初期ルート: {page.route}"))

    def route_change(e: ft.RouteChangeEvent):
        page.add(ft.Text(f"変更後ルート: {page.route}"))

        troute = ft.TemplateRoute(page.route)
        if troute.match("/books/:id"):
            page.add(ft.Text(f"書籍ID: {troute.id}"))
        elif troute.match("/account/:account_id/orders/:order_id"):
            page.add(ft.Text(f"アカウント: {troute.account_id}, 注文ID: {troute.order_id}"))
        else:
            page.add(ft.Text("定義されていないルート"))

    page.on_route_change = route_change
    page.update()

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

初期状態

/books/11111にアクセス

/account/22222/orders/33333にアクセス

WebのURL戦略

FletにおけるURLベースのルーティングは以下の2種類がサポートされている

  • Path
    • デフォルト
    • パスがハッシュなしで読み書きされる
    • 例: fletapp.dev/path/to/view
  • Hash
    • パスがハッシュフラグメントに読み書きされる
    • 例: fletapp.dev/#/path/to/view

なるほど、上の方で

別ウインドウ・別タブで開いて、URLに/testを追加すると、初期ルートが変わっているのがわかる。なお、ドキュメントにある通り/#/testとしても何も変わらなかった。

というのはここの設定だったのか。

これ変更するには、ft.app(main, route_url_strategy="hash")とすればよい。一つ上のコードで修正してみた。

import flet as ft

def main(page: ft.Page):
    page.title = "ルーティングテンプレートのサンプル"

    page.add(ft.Text(f"初期ルート: {page.route}"))

    def route_change(e: ft.RouteChangeEvent):
        page.add(ft.Text(f"変更後ルート: {page.route}"))

        troute = ft.TemplateRoute(page.route)
        if troute.match("/books/:id"):
            page.add(ft.Text(f"書籍ID: {troute.id}"))
        elif troute.match("/account/:account_id/orders/:order_id"):
            page.add(ft.Text(f"アカウント: {troute.account_id}, 注文ID: {troute.order_id}"))
        else:
            page.add(ft.Text("定義されていないルート"))

    page.on_route_change = route_change
    page.update()

ft.app(main, view=ft.AppView.WEB_BROWSER, route_url_strategy="hash")  # Hash戦略に変更

こうなる

逆に#なしだとこうなる

kun432kun432

FletアプリをiOSでテストする

https://flet.dev/docs/getting-started/testing-on-ios

Fletで作成したiOSアプリを実際のデバイス上でテストできるらしい。まず、Fletが公式に出しているiOSアプリをインストールする。ドキュメントにはQRコードがあるが、App Storeのページだとこれ。

https://apps.apple.com/jp/app/flet/id1624979699

インストール

でローカルでFletアプリを用意する。とりあえず「アダプティブアプリ」の項で出てきたコードにしてみる。

main.py
import flet as ft


def main(page):
    # アダプティブ有効
    page.adaptive = True

    page.appbar = ft.AppBar(
        leading=ft.TextButton("New", style=ft.ButtonStyle(padding=0)),
        title=ft.Text("Adaptive AppBar"),
        actions=[
            ft.IconButton(ft.cupertino_icons.ADD, style=ft.ButtonStyle(padding=0))
        ],
        bgcolor=ft.colors.with_opacity(0.04, ft.cupertino_colors.SYSTEM_BACKGROUND),
    )

    page.navigation_bar = ft.NavigationBar(
        destinations=[
            ft.NavigationBarDestination(icon=ft.icons.EXPLORE, label="Explore"),
            ft.NavigationBarDestination(icon=ft.icons.COMMUTE, label="Commute"),
            ft.NavigationBarDestination(
                icon=ft.icons.BOOKMARK_BORDER,
                selected_icon=ft.icons.BOOKMARK,
                label="Bookmark",
            ),
        ],
        border=ft.Border(
            top=ft.BorderSide(color=ft.cupertino_colors.SYSTEM_GREY2, width=0)
        ),
    )

    page.add(
        ft.SafeArea(
            ft.Column(
                [
                    ft.Checkbox(value=False, label="Dark Mode"),
                    ft.Text("First field:"),
                    ft.TextField(keyboard_type=ft.KeyboardType.TEXT),
                    ft.Text("Second field:"),
                    ft.TextField(keyboard_type=ft.KeyboardType.TEXT),
                    ft.Switch(label="A switch"),
                    ft.FilledButton(content=ft.Text("Adaptive button")),
                    ft.Text("Text line 1"),
                    ft.Text("Text line 2"),
                    ft.Text("Text line 3"),
                ]
            )
        )
    )


ft.app(main)

--iosをつけてFletで作成したアプリを起動。なお、Fletアプリを起動した端末とiOSデバイスは同じWiFi LAN内に繋がっている必要がある。

$ flet run --ios my_flet_app

こんな感じでQRコードが表示されるので、iOSデバイスで読み取り。

iOSデバイスのFletアプリが起動してアプリが表示される。

なお、起動時に-dをつけていなくても、ホットリロードは有効みたいで、UIのラベルを変更して保存するとすぐに反映された。

デバイスをシェイクするか3本指タッチでiOSアプリのホームに戻る。

ホームからはあらかじめ用意されたデモなどを試すこともできる。

kun432kun432

非同期アプリ

Fletでも非同期が使える。Fletのデフォルトでは、controlのイベントハンドラを別々のスレッドで実行するようになっているが、CPUを効率的に使えなかったり、HTTPレスポンス待ちやsleep()の間は何もしないなどが起きてしまう。非同期にすることで、単一スレッドで並行処理が実装できる。特にPyodideでスタティックなWebアプリを作る場合には重要らしい。

非同期を使うにはmain()async関数として定義してその中で非同期処理を行えば良い。

import flet as ft

async def main(page: ft.Page):
    await asyncio.sleep(1)
    page.add(ft.Text("こんにちは、非同期な世界!"))

ft.app(main)

controlのイベントハンドラを非同期で。asyncio.sleep()も使っている。

import asyncio
import flet as ft

async def main(page: ft.Page):

    async def sleep_and_greet(count):
        t = ft.Text(f"カウントダウン: {count}")
        page.add(t)
        for i in range(count, 0, -1):
            t.value = f"カウントダウン: {i}"
            page.update()
            await asyncio.sleep(1)
        t.value = f"カウントダウン: 0"
        page.update()

    async def button_click(e):
        page.add(ft.Text("ボタンがクリックされました!5秒後に挨拶します。"))
        await sleep_and_greet(5)
        page.add(ft.Text("こんにちは!"))

    page.add(ft.ElevatedButton("非同期で挨拶する", on_click=button_click))

ft.app(main)

page.run_task()を使えばバックグラウンド処理ができる。

import asyncio
import flet as ft

class Countdown(ft.Text):
    def __init__(self, seconds):
        super().__init__()
        self.seconds = seconds

    def did_mount(self):
        self.running = True
        self.page.run_task(self.update_timer)

    def will_unmount(self):
        self.running = False

    async def update_timer(self):
        while self.seconds and self.running:
            mins, secs = divmod(self.seconds, 60)
            self.value = "{:02d}:{:02d}".format(mins, secs)
            self.update()
            await asyncio.sleep(1)
            self.seconds -= 1
        self.value = "{:02d}:{:02d}".format(0, 0)
        self.update()

def main(page: ft.Page):
    page.add(Countdown(10), Countdown(5))

ft.app(main)

kun432kun432

まとめ

Gettting Started一通りやってみたが、たしかにお手軽にWebアプリ/iOSアプリ/Androidアプリが作成できそうな気がする。StreamlitやGradioで作る場合と比べるといろいろ考えないといけないことが多くなる気はするけど、その分自由度も高くなると思うし、クロスプラットフォームで作れるというのはやっぱり大きい。そういえば昔Flutterちょっと触ったことがあって、このあたりはFlutterの恩恵もあるんだろう。

自分はOpenAI Realtime APIの簡単なデモを書きたいなと思って、WebSocket全く知らなかったので調べてみたのだけど、StreamlitやGradioだと仕組み上難しそうに思ったので、Fletを触ってみた次第。実際にChatGPTに聞きながら、シンプルなWebSocketクライアント(ブラウザ)・サーバも書けた(簡単ではないけども)。

https://zenn.dev/link/comments/2a7bb4e73e433a

実際にスマホアプリまで作るにはいろいろ調べないといけないと思うけども、少なくとも、できるかもとは思わせてくれたので、もう少し色々触ってみたいと思う。

このスクラップは2ヶ月前にクローズされました