Skip to content

浏览器只会在处理一次点击或按键的瞬间,才允许某些操作:打开文件选择器、写入剪贴板、显示分享面板、打开新标签页。这个权限只在该次手势期间有效,过后即失效。

这对 Flet 通常的模式是个问题。当你在 on_click 事件处理器中调用 FilePicker.pick_files() 时,点击会先到达你的 Python 代码, 你的代码执行,然后打开对话框的指令再传回去——而到那时,浏览器已经不再认为有手势正在进行,于是悄悄地拒绝执行。

Safari 对此执行得非常严格,Chrome 和 Firefox 则比较宽松,因此症状令人困惑:同一个应用,在 Android 和桌面端都能正常工作, 在 iPhone 或 iPad 上却悄无声息地什么都不做。什么日志都不会输出,因为在浏览器看来一切正常。

客户端动作(client actions) 弥补了这个缺口。动作是绑定在控件上、而不是从事件处理器中调用的,因此客户端在点击到来时 就已经知道要做什么,并能在手势内立即执行:

python
ft.Button("Upload", action=ft.PickFiles(file_picker, allow_multiple=True))

打开 URL ​

OpenUrl 用来打开一个链接。其中新标签页是浏览器重点防护的部分,因为那正是弹窗拦截器要阻止的行为。

python
import flet as ft


def main(page: ft.Page):
    # `action` 由客户端在处理点击的过程中执行,因此打开新标签页不会被当作
    # 未经请求的弹窗。对比 `UrlLauncher().launch_url()`,它必须先到达 Python,
    # 因此在 iOS 的 Safari 上会被拦截。
    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)

控件一直以来就拥有的 url 属性工作方式与此相同、保持不变—— OpenUrl 适用于你想把它和其他动作组合,或者让所有受手势门控的操作以同一种方式书写的场景。

复制到剪贴板 ​

python
import flet as ft


def main(page: ft.Page):
    # `action` 由客户端在处理点击的过程中执行。这是 Safari 允许页面写入剪贴板的
    # 唯一时刻,也正因如此,`Clipboard().set()`——它必须先到达 Python——在 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)

分享 ​

python
import flet as ft


def main(page: ft.Page):
    # `action` 由客户端在处理点击的过程中执行。浏览器只在一次手势期间才会打开
    # 分享面板,因此从 Python 调用的 `Share()` 在 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)

选择文件 ​

PickFiles 是用于修复 Web 应用在 iOS 上文件选择问题的动作。

因为对话框是在你的代码看到点击之前就打开的,所选结果无法像 pick_files() 那样返回给调用者。它会被送到 FilePicker.on_result。被选中的文件仍然与 FilePicker 相关联,因此 upload() 的工作方式与之前完全一致。

python
#
# 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")

每个动作对应什么 ​

动作等价方法
OpenUrlUrlLauncher.launch_url()
CopyToClipboardClipboard.set()
ShareTextShare.share_text()
PickFilesFilePicker.pick_files()

动作先执行,然后你的 on_click 事件处理器照常被调用——因此你仍然可以在 Python 中响应该次点击。

如果一个控件需要多个动作,它既接受单个动作,也接受动作列表:

python
ft.Button(
    "Copy and open",
    action=[ft.CopyToClipboard(link), ft.OpenUrl(link, target=ft.UrlTarget.BLANK)],
)

限制 ​

动作的参数在点击之前就已固定。 这源于动作的本质——客户端必须事先就掌握整个操作,因为它没有时间去询问。 要复制或分享一个会变化的值,请在值变化时更新动作,正如上面剪贴板示例所做的那样。对此毫无办法;这是浏览器的规则,而不是 Flet 的。

读取剪贴板依然会弹出提示。 无论你怎么做,Safari 都会为 Clipboard.get() 显示粘贴确认界面。 动作只是让读取成为可能,而不是让它不可见。

在浏览器之外,这不适用。 在桌面端、Android 和 iOS 应用中没有这种限制,普通的调用方式也能正常工作。 动作在任何地方都可用,因此如果你的应用也会运行在 Web 端,你可以无条件地使用它们——但如果不会,也就没有需要修复的问题。


关注我,学习大模型、大数据、Python等热门技术。