diff --git a/packages/flet/lib/src/controls/container.dart b/packages/flet/lib/src/controls/container.dart index 124564cfe..1684977e4 100644 --- a/packages/flet/lib/src/controls/container.dart +++ b/packages/flet/lib/src/controls/container.dart @@ -50,7 +50,7 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { Widget? container; var onAnimationEnd = control.getBool("on_animation_end", false)! - ? () => control.triggerEvent("animation_end" "container") + ? () => control.triggerEvent("animation_end", "container") : null; if ((onClick || url != null || onLongPress || onHover || onTapDown) && ink && diff --git a/packages/flet/lib/src/controls/page.dart b/packages/flet/lib/src/controls/page.dart index 7edb16519..683561e2b 100644 --- a/packages/flet/lib/src/controls/page.dart +++ b/packages/flet/lib/src/controls/page.dart @@ -61,6 +61,9 @@ class _PageControlState extends State with WidgetsBindingObserver { bool _keyboardHandlerSubscribed = false; String? _prevViewRoutes; + // Ensures that the first_frame lifecycle event is fired only once. + bool _firstFrameEventSent = false; + final Map _multiViews = {}; bool _registeredFromMultiViews = false; List? _appliedDeviceOrientations; @@ -72,6 +75,7 @@ class _PageControlState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); _updateMultiViews(); + _scheduleFirstFrameNotification(); _routeParser = RouteParser(); @@ -252,6 +256,18 @@ class _PageControlState extends State with WidgetsBindingObserver { } } + /// Schedules a one-time callback to emit the `first_frame` lifecycle event + /// after the first Flutter frame is rendered for this page. + void _scheduleFirstFrameNotification() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _firstFrameEventSent) { + return; + } + _firstFrameEventSent = true; + widget.control.triggerEventWithoutSubscribers("first_frame"); + }); + } + void _routeChanged() { FletBackend.of(context).onRouteUpdated(_routeState.route); } @@ -341,8 +357,7 @@ class _PageControlState extends State with WidgetsBindingObserver { var appStatus = context .select((backend) => (isLoading: backend.isLoading, error: backend.error)); - var appStartupScreenMessage = - backend.appStartupScreenMessage ?? ""; + var appStartupScreenMessage = backend.appStartupScreenMessage ?? ""; var formattedErrorMessage = backend.formatAppErrorMessage(appStatus.error); @@ -503,14 +518,12 @@ class _PageControlState extends State with WidgetsBindingObserver { var backend = FletBackend.of(context); var showAppStartupScreen = backend.showAppStartupScreen ?? false; - var appStartupScreenMessage = - backend.appStartupScreenMessage ?? ""; + var appStartupScreenMessage = backend.appStartupScreenMessage ?? ""; var appStatus = context.select( (backend) => (isLoading: backend.isLoading, error: backend.error)); - var formattedErrorMessage = - backend.formatAppErrorMessage(appStatus.error); + var formattedErrorMessage = backend.formatAppErrorMessage(appStatus.error); var views = widget.control.children("views"); List> pages = []; diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 2958ac5a7..96f395296 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -1,4 +1,5 @@ import asyncio +import inspect import logging import sys import threading @@ -424,6 +425,18 @@ class Page(BasePage): """ TBD """ + + on_first_frame: Optional[ControlEventHandler["Page"]] = None + """ + Called once after the client renders the very first frame. + + Useful for starting implicit animations or other work that must wait until + the initial layout completes. + + Pair with [`Page.post_frame_callback()`][flet.Page.post_frame_callback] to register + callbacks without wiring up an explicit event handler. + """ + _services: list[Service] = field(default_factory=list) _user_services: ServiceRegistry = field(default_factory=lambda: ServiceRegistry()) @@ -447,6 +460,8 @@ def __post_init__( self.__last_route = None self.__query: QueryString = QueryString(self) self.__authorization: Optional[Authorization] = None + self.__first_frame_callbacks: list[Callable[[], Any]] = [] + self.__first_frame_fired = False def get_control(self, id: int) -> Optional[BaseControl]: """ @@ -523,8 +538,56 @@ def before_event(self, e: ControlEvent): if view_index is not None: e.view = views[view_index] + elif e.name == "first_frame": + self.__handle_first_frame_event() + return super().before_event(e) + def post_frame_callback(self, callback: Callable[[], Any]): + """ + Schedule a callable to run immediately after the page finishes + rendering its very first frame. + + Args: + callback: A synchronous function or coroutine function to execute after the + initial layout completes. Already-rendered pages trigger the callback + immediately. + + Raises: + TypeError: If `callback` is not callable. + """ + if not callable(callback): + raise TypeError("callback must be callable") + + if self.__first_frame_fired: + self.__run_first_frame_callback(callback) + else: + self.__first_frame_callbacks.append(callback) + + def __handle_first_frame_event(self): + """Drain and execute callbacks when the Flutter client signals first frame.""" + if self.__first_frame_fired: + return + + self.__first_frame_fired = True + callbacks = self.__first_frame_callbacks[:] + self.__first_frame_callbacks.clear() + for cb in callbacks: + self.__run_first_frame_callback(cb) + + def __run_first_frame_callback(self, callback: Callable[[], Any]): + """Execute a queued callback asynchronously, awaiting it if needed.""" + + async def _runner(): + try: + result = callback() + if inspect.isawaitable(result): + await result + except Exception: + logger.exception("Error running post_frame_callback callback") + + self.run_task(_runner) + def run_task( self, handler: Callable[InputT, Awaitable[RetT]],