Client actions
Some things a browser can do are only allowed while it is handling a click or a key press: opening a file picker, writing to the clipboard, showing a share sheet, opening a new tab. The permission lasts for that one gesture and no longer.
That is a problem for the usual Flet pattern. When you call
FilePicker.pick_files()
from an on_click handler, the click travels to your Python code, your code
runs, and the instruction to open the dialog travels back - by which point the
browser no longer considers a gesture to be in progress and quietly refuses.
Safari enforces this strictly, Chrome and Firefox are lenient about it, so the symptom is confusing: the same app works on Android and on the desktop, and silently does nothing on an iPhone or iPad. Nothing is logged, because from the browser's point of view nothing went wrong.
Client actions close that gap. An action is attached to a control instead of being called from a handler, so the client already knows what to do when the tap arrives and can do it immediately, inside the gesture:
ft.Button("Upload", action=ft.PickFiles(file_picker, allow_multiple=True))
Opening a URL
OpenUrl opens a link. Opening a new tab is the part
that browsers guard, since that is what a popup blocker exists to stop.
import flet as ft
def main(page: ft.Page):
# `action` is performed by the client while it is still handling the click,
# so opening a new tab is not treated as an unsolicited popup. Compare with
# `UrlLauncher().launch_url()`, which has to reach Python first and is
# therefore blocked by Safari on iOS.
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text("Both buttons open the same page:"),
ft.Button(
"Open in this tab",
action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.SELF),
),
ft.Button(
"Open in a new tab",
action=ft.OpenUrl(
"https://flet.dev",
target=ft.UrlTarget.BLANK,
),
),
ft.Text(
"An action can be combined with on_click - the action "
"runs on the client, then your handler runs in Python."
),
ft.Button(
"Open and log",
action=ft.OpenUrl("https://flet.dev/docs"),
on_click=lambda e: page.show_dialog(
ft.SnackBar(ft.Text("Docs opened"))
),
),
],
),
)
)
if __name__ == "__main__":
ft.run(main)
The url property that controls have
always had works the same way and is unchanged - OpenUrl is for when you want
to combine it with other actions, or keep every gesture-gated operation written
the same way.
Copying to the clipboard
import flet as ft
def main(page: ft.Page):
# `action` is performed by the client while it is still handling the click.
# That is the only moment Safari lets a page write to the clipboard, which
# is why `Clipboard().set()` - which has to reach Python first - does
# nothing on iOS.
token = "flet-1234-5678"
def handle_copied(e):
page.show_dialog(ft.SnackBar(ft.Text("Copied to clipboard")))
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text(f"Token: {token}", selectable=True),
ft.Button(
"Copy token",
icon=ft.Icons.CONTENT_COPY,
action=ft.CopyToClipboard(token),
on_click=handle_copied,
),
ft.Divider(),
ft.Text(
"An action's arguments are fixed before the click, so "
"to copy something typed just now, update the action "
"as the text changes."
),
note := ft.TextField(
label="Note",
value="Edit me, then copy",
on_change=lambda e: setattr(
copy_note, "action", ft.CopyToClipboard(note.value)
),
),
copy_note := ft.Button(
"Copy note",
icon=ft.Icons.CONTENT_COPY,
action=ft.CopyToClipboard("Edit me, then copy"),
on_click=handle_copied,
),
],
),
)
)
if __name__ == "__main__":
ft.run(main)
Sharing
import flet as ft
def main(page: ft.Page):
# `action` is performed by the client while it is still handling the click.
# Browsers only open the share sheet during a gesture, so `Share()` called
# from Python has no effect on the web.
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text("Share this page with someone:"),
ft.Button(
"Share",
icon=ft.Icons.SHARE,
action=ft.ShareText(
"Flet lets you build multi-platform apps in Python: "
"https://flet.dev",
subject="Flet",
),
),
ft.Text(
"The share sheet is a system dialog - what it offers "
"depends on the platform, and on desktop browsers it "
"may not be available at all."
),
],
),
)
)
if __name__ == "__main__":
ft.run(main)
Picking files
PickFiles is the action that fixes file picking in a
web app on iOS.
Because the dialog opens before your code sees the click, the selection cannot
be returned to a caller the way pick_files() returns it. It arrives at
FilePicker.on_result
instead. The picked files stay associated with the FilePicker, so
upload() works exactly as
before.
#
# Picking and uploading files in a way that also works in a web app on iOS.
#
# Run this example with:
# export FLET_SECRET_KEY=<some_secret_key>
# uv run flet run --web examples/services/file_picker/pick_files_action/main.py
#
from dataclasses import dataclass, field
import flet as ft
@dataclass
class State:
picked_files: list[ft.FilePickerFile] = field(default_factory=list)
state = State()
def main(page: ft.Page):
prog_bars: dict[str, ft.ProgressRing] = {}
def handle_upload_progress(e: ft.FilePickerUploadEvent):
prog_bars[e.file_name].value = e.progress
def handle_result(e: ft.FilePickerResultEvent):
# A PickFiles action opens the dialog before Python sees the click, so
# the selection arrives here instead of being returned to a caller.
state.picked_files = e.files
prog_bars.clear()
upload_progress.controls.clear()
for f in e.files:
prog = ft.ProgressRing(value=0, bgcolor="#eeeeee", width=20, height=20)
prog_bars[f.name] = prog
upload_progress.controls.append(
ft.Row([prog, ft.Text(f"{f.name} ({f.size} bytes)")])
)
upload_button.disabled = len(e.files) == 0
async def handle_file_upload(e: ft.Event[ft.Button]):
upload_button.disabled = True
# The picked files stay on the FilePicker, so upload() takes them as-is.
await file_picker.upload(
files=[
ft.FilePickerUploadFile(
name=file.name,
upload_url=page.get_upload_url(f"dir/{file.name}", 60),
)
for file in state.picked_files
]
)
file_picker = ft.FilePicker(
on_result=handle_result,
on_upload=handle_upload_progress,
)
page.services.append(file_picker)
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Button(
content="Select files...",
icon=ft.Icons.FOLDER_OPEN,
# Attaching the pick to the control - rather than
# calling file_picker.pick_files() from an on_click
# handler - is what makes the dialog open on iOS.
action=ft.PickFiles(file_picker, allow_multiple=True),
),
upload_progress := ft.Column(),
upload_button := ft.Button(
content="Upload",
icon=ft.Icons.UPLOAD,
on_click=handle_file_upload,
disabled=True,
),
],
),
)
)
if __name__ == "__main__":
ft.run(main, upload_dir="examples")
What each action maps to
| Action | Equivalent method |
|---|---|
OpenUrl | UrlLauncher.launch_url() |
CopyToClipboard | Clipboard.set() |
ShareText | Share.share_text() |
PickFiles | FilePicker.pick_files() |
An action runs first, then your on_click handler is called as usual - so you
can still react to the click in Python.
A control accepts a list as well as a single action, if you need more than one:
ft.Button(
"Copy and open",
action=[ft.CopyToClipboard(link), ft.OpenUrl(link, target=ft.UrlTarget.BLANK)],
)
Limits
An action's arguments are fixed before the click. This follows from what an action is - the client has to know the whole operation in advance, because there is no time to ask. To copy or share a value that changes, update the action when the value changes, as the clipboard example above does. There is no way around this; it is the browser's rule, not Flet's.
Reading the clipboard still prompts. Safari shows a paste-confirmation UI
for Clipboard.get() whatever you
do. An action makes the read possible, not invisible.
Outside the browser this does not apply. On desktop, Android and iOS apps there is no such restriction, and the ordinary method calls work fine. Actions work everywhere, so you can use them unconditionally if your app also runs on the web - but there is nothing to fix if it does not.