Pythonでクロスプラットフォームなアプリが作れる「Flet」を試す
はじめに
Fletとは?
Fletは、フロントエンド開発の経験がなくても、PythonでWeb、デスクトップ、モバイルアプリケーションを構築できるフレームワークである。
Flet controlsはGoogleのFlutterをベースとしており、これを使用してプログラムのUIを構築できる。Fletは、Flutterウィジェットを単にラップするだけにとどまらない。より小さなウィジェットを組み合わせ、複雑な部分を簡素化し、UIのベストプラクティスを実装し、適切なデフォルト値を設定することで、独自のタッチを加えている。これにより、ユーザー側でデザインに追加の労力をかけることなく、スタイリッシュで洗練されたアプリケーションを実現できる。
サンプルの「カウンター」アプリを動かしてみる。ローカルのMacで。
仮想環境を用意しておいて、パッケージインストール
$ pip install flet
$ flet --version
0.24.1
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.
Getting Started
Getting Startedに従って進めていく。最初のインストールはすでに終わってるので、そのまま進める
Fletアプリの新規作成
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
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
Fletアプリの実行
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を書き換えると反映される。
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)
カスタムな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
Column
、Row
、Stack
といったレイアウトに関する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_isolated
がTrue
の場合、そのcontrolは外側のレイアウトから分離されるので、親のcontrolでupdate()
が行われても、子control自体は更新はされるが、子controlへの変更は更新されない、らしい?デフォルトはFalse
。
なんとなく雰囲気はわかるんだけど、書いてある例がピンとこなかったので、多分まだ理解できてない。
アダプティブアプリ
Fletは単一のコードベースでプラットフォームごとに異なる複数の見た目を提供する「アダプティブアプリ」に対応している。
「レスポンシブ」と何が違うのかな?と思ったんだけど、スマホとPCでの操作インタフェースの違いとかプラットフォーム固有機能なども含めて、ということらしい。このあたりはFlutterがベースになっているので自ずとそうなるのだろうと思う。
紹介されているサンプルは以下のようなものになっていて、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も自動的にアダプティブになるらしいけど、細かく設定したりってのもできるみたい。
ナビゲーションとルーティング
ページ遷移と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
を変更して、あたらしいview
をpage.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戦略に変更
こうなる
逆に#
なしだとこうなる
FletアプリをiOSでテストする
Fletで作成したiOSアプリを実際のデバイス上でテストできるらしい。まず、Fletが公式に出しているiOSアプリをインストールする。ドキュメントにはQRコードがあるが、App Storeのページだとこれ。
インストール
でローカルでFletアプリを用意する。とりあえず「アダプティブアプリ」の項で出てきたコードにしてみる。
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アプリのホームに戻る。
ホームからはあらかじめ用意されたデモなどを試すこともできる。
FletアプリをAndroidデバイスでテストする
AndroidデバイスでもiOSデバイスと同様のことができる。ここは割愛。
非同期アプリ
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)
まとめ
Gettting Started一通りやってみたが、たしかにお手軽にWebアプリ/iOSアプリ/Androidアプリが作成できそうな気がする。StreamlitやGradioで作る場合と比べるといろいろ考えないといけないことが多くなる気はするけど、その分自由度も高くなると思うし、クロスプラットフォームで作れるというのはやっぱり大きい。そういえば昔Flutterちょっと触ったことがあって、このあたりはFlutterの恩恵もあるんだろう。
自分はOpenAI Realtime APIの簡単なデモを書きたいなと思って、WebSocket全く知らなかったので調べてみたのだけど、StreamlitやGradioだと仕組み上難しそうに思ったので、Fletを触ってみた次第。実際にChatGPTに聞きながら、シンプルなWebSocketクライアント(ブラウザ)・サーバも書けた(簡単ではないけども)。
実際にスマホアプリまで作るにはいろいろ調べないといけないと思うけども、少なくとも、できるかもとは思わせてくれたので、もう少し色々触ってみたいと思う。
実際にアプリを公開したりデプロイしたりはここ
チュートリアルが多数あるので参考になりそう。
オーディオを扱うにはflet_audioとflet_audio_recorderを使えば良さそう。