Embedding

Flutter views inside native screens and native views inside Flutter - how each works, what it costs, and what we suggest trying before you commit to either.

Most of these docs assume a clean handover between the two worlds: a screen is either Flutter or native, and the two only meet when the user navigates from one to the other. Embedding is the case where that stops being true. A Flutter card sits inside a native scroll view, or a native ad banner sits inside a Flutter list. Two layout systems and two gesture systems now share one screen, and the boundary that used to be crossed once per navigation is now crossed on every frame.

This page covers both directions: Flutter inside native (partial views) and native inside Flutter (platform views). Our advice is the same one most teams we interviewed arrived at on their own: avoid embedding whenever you can. It is solvable, but it eats time that would otherwise go into migrating screens, which is why the planning guide already rules it out for a first screen. Below we explain where the cost comes from, what to try instead, and how to do it properly if there is really no other way.

Why embedding is where add-to-app gets hard

Full-screen containers work well because each technology stays in charge of its own screen. Flutter gets the whole surface: it lays out against a known size, it receives every touch first, and the container it lives in tells it when it is shown and hidden. Embedding takes all three away at once.

  • Layout - somebody has to tell the embedded view how big it is, and neither side naturally knows what the other wants. A Flutter surface simply renders at whatever size it is given, and a native UIView or Android View inside a Flutter widget tree has to be measured through a box that Flutter controls.
  • Gestures - two gesture systems compete for the same touches. The typical symptom is a horizontal carousel inside a vertically scrolling parent: whichever side recognizes the drag first wins, and in our experience it is usually the wrong one.
  • Lifecycle - a full-screen container is created, shown, hidden, and destroyed in a predictable order. An embedded view can be scrolled off-screen, recycled, re-attached, or covered while its host stays resumed, and both the Flutter engine and native SDKs expect to be told about these transitions.

On top of that, the two renderers have to be composited into one frame. How that composition happens differs by direction, and it is where most of the performance cost sits.

Flutter inside a native screen (partial views)

A partial view is a Flutter surface that takes up part of a native screen: a Flutter widget in a native dashboard, a Flutter tab inside a native tab bar, a Flutter row in a native list. Technically it is the same object as a full-screen container - a FlutterFragment or FlutterView on Android, a FlutterViewController on iOS - only with a smaller frame. Unfortunately, everything around that object gets more complicated.

It costs one engine per view

An engine drives one rendering surface at a time (see Core concepts), so each Flutter view visible on the screen is a separate engine. FlutterEngineGroup keeps the marginal cost low - on the order of 180 kB and a fraction of the cold start - but it is not free. Every view is its own isolate with its own heap, runs its own copy of your Dart startup code, holds its own instances of every plugin, and has to render its own first frame. A screen with four Flutter cards is running four Dart programs at the same time. Inlay's hidden prewarmed engine keeps the group's shared resources loaded, but every view still spawns its own engine and renders its own first frame. In one of our earlier projects, the first frame of a Flutter screen on an already warm engine was still around 100 ms behind a native screen.

Because the views are separate isolates, they cannot share Dart state with each other. Two Flutter cards on one native screen are as isolated as two screens in different parts of the app. Anything both of them need to see has to go through the platform - with Inlay, through a store.

Sizing: the view doesn't know how tall it is

Until recently, a Flutter view had to be given a fixed size by its native parent. That is fine for a tab or a banner of known height, but not for content whose height depends on data. One team we know of embedded a dashboard card whose height varied with the user's data, and depending on the state the card was cut off or did not show up at all. Their fix was a GlobalKey on the root widget, a RenderBox measurement after layout, and a method channel call asking the native side to resize the container. It works, but it is exactly the kind of hand-written plumbing you want as little of as possible.

Flutter 3.41 added content-sized views, which remove most of this. On iOS you set isAutoResizable on the FlutterViewController; on Android you enable content sizing in the manifest and give the FlutterView a content_wrap dimension:

let vc = FlutterViewController(engine: engine, nibName: nil, bundle: nil)
vc.isAutoResizable = true

The catch is on the Dart side. The root of the tree has to lay out under unbounded constraints, so anything that needs a size from its parent - ListView, GridView, LayoutBuilder, Scaffold - cannot be at the top of an auto-sized view. In practice, the thing you embed is a self-sizing Column or a card, not a screen. A quick way to check a widget is to wrap it in an UnconstrainedBox in a standalone Flutter app and see whether it renders without overflow errors.

Gestures: nested scrolling

A partial view almost always ends up inside a native scroll view, and as soon as it contains anything that scrolls or drags itself, the two gesture systems start fighting. The same team ran into this with a horizontal carousel inside their card: the native scroll view treated every drag as vertical scrolling, and scrolling the carousel became close to impossible. Their workaround was a drag detector in Flutter that, once it recognized a horizontal drag, told the native side over a method channel to disable vertical scrolling in the scroll view for the duration of the gesture.

There is no general fix here, because there is no shared gesture arena across the boundary. Every embedded view with interactive content needs its own small protocol negotiated with its host, and that protocol is yours to write and maintain.

Lifecycle: prefer Fragment and view controller over raw views

On Android, FlutterFragment (and Inlay's InlayFlutterFragment) is lifecycle-aware: it attaches and detaches the engine and forwards resume and pause as the host does. A raw FlutterView is not. You have to call attachToFlutterEngine yourself, drive lifecycleChannel.appIsResumed() / appIsPaused(), and wire up clipboard, input method, and system UI handling. Recycling Flutter views inside a RecyclerView is the worst case. Flutter's issue tracker has a history of bugs specific to this setup - views rendering black after being re-attached during fast scrolling, for instance - and even with those fixed, you are still driving the engine lifecycle by hand for every row. If a Flutter surface has to live in a recycled list row, the boundary is most likely in the wrong place.

On iOS, add the FlutterViewController as a child view controller, so that viewWillAppear / viewDidDisappear reach it. Those callbacks are where the controller activates and deactivates the rendering surface and sends the resumed and paused lifecycle events to Dart. If you only add its view, none of that happens, and Flutter treats itself as visible and resumed the whole time.

Where Inlay stands on partial views

Inlay's container model is built around full-screen and dialog containers. An engine is created when a container is pushed and destroyed when it pops, and pop() from Flutter finishes the Activity, pops the NavigationStack, or dismisses the dialog. The building blocks can be put into a smaller frame - InlayNavigator.createFragment into any FragmentContainerView, createFlutterViewController as a child view controller, InlayFlutterScreen with a sized Modifier, InlayFlutterView inside a SwiftUI layout - and you still get the engine, the typed route, and the shared store. However, Inlay 0.1 does not have a first-class partial-view API. Content sizing, nested-scroll coordination, and a pop() that means "remove this view" rather than "close this screen" are all left to you. If you go down this path anyway, use the Compose wrapper or createFragment with useBackDispatcher = true on Android (so that pop goes through the back dispatcher instead of calling finish() on the host Activity), keep the embedded widget self-sizing, and plan for writing the gesture protocol yourself.

Native views inside Flutter (platform views)

The other direction is a native View / UIView rendered inside a Flutter widget tree: AndroidView or UiKitView in Dart, backed by a factory registered on the native side. Most developers meet platform views through plugins rather than directly - google_maps_flutter, webview_flutter, google_mobile_ads banners, and some video players are platform views underneath.

How they are composited, and what it costs

Flutter renders to its own surface, while a platform view is an object in the native view hierarchy. Putting one inside the other means compositing two pipelines, and each platform does it differently.

On Android there are three modes:

ModeHow it worksCost
Texture Layer (default, AndroidView)The native view renders into a texture that Flutter draws like any other layer.Good performance, all Flutter transforms work. Janky for fast-updating content such as scrolling WebViews; accessibility is broken for SurfaceView-based platform views; text magnification breaks unless Flutter itself renders into a TextureView.
Hybrid Composition (PlatformViewLink + initSurfaceAndroidView)The native view stays in the Android hierarchy; Flutter content above and below it renders into ImageReader-backed views the Android compositor stacks.Full native fidelity and accessibility, but Flutter's raster thread merges into the platform thread for as long as the view is on screen. Complex Flutter UI now competes with OS messages and plugin calls for one thread, and frame drops follow.
Hybrid Composition++ (experimental, Flutter 3.44+, opt-in via the EnableHcpp manifest meta-data)Both Flutter and the platform view render to native surfaces that SurfaceFlinger composites.Fidelity of HC with the performance of Texture Layer, but requires Android API 34+ and Impeller's Vulkan backend (not the OpenGL ES fallback), and has known stacking limitations with transparent views.

iOS has a single mode, hybrid composition: the UIView is inserted into the view hierarchy and Flutter content is split into layers around it. It works, but it comes with its own trade-offs: animations over a platform view can get slow, ShaderMask and ColorFiltered cannot be applied to it, and BackdropFilter only works with limitations.

The Flutter docs suggest the same trick for animations on both platforms: take a screenshot of the native view, show it as a plain texture while the transition runs, and swap the live view back in afterwards. It gives a fair idea of how expensive the live path is.

Platform views in a multi-engine setup

In a regular Flutter app you register a platform view factory once. In an engine-per-container model every engine needs the factory, the same way it needs every plugin (see Plugins). With Inlay this means setOnEngineCreated: on Android the hook is the intended place for factory registration, and on iOS the factory goes through the engine's plugin registrar.

InlayNavigator.shared.setOnEngineCreated { engine in
    GeneratedPluginRegistrant.register(with: engine)
    let registrar = engine.registrar(forPlugin: "AdBannerView")!
    registrar.register(
        AdBannerViewFactory(messenger: registrar.messenger()),
        withId: "ad-banner"
    )
}

Registration is the easy part. What you need to plan for is that the native objects behind a platform view belong to one engine, and engines belong to containers. A map, a WebView, or an ad SDK view is created from scratch every time a Flutter screen using it is pushed, and torn down when it pops. For a map that means a visible cold start on every navigation. For an ad SDK it means the SDK's own view lifecycle - impression tracking, viewability, preloading - now runs against a view that lives as long as a screen rather than as long as the app. Anything the SDK expects to persist has to be kept on the native side and reattached, which is the same problem as plugins with state, only with a view attached to it.

What it looked like in practice

One team we interviewed rewrote their home screen in Flutter first. To ship it at all they had to embed native widgets in the Flutter screen, because their internal advertising library could not be reimplemented in Flutter. The screen did ship, but the company came out of it with an internal policy to avoid embedding in either direction. They were not alone in this. Another team we work with made a similar observation: people tend to think about embedding individual controls, and in most cases it is easier and faster to rewrite the control than to get the embedding right. The full story is in the patterns we've seen.

What to do instead

In our experience, most "we need Flutter inside this native screen" requirements turn out to be requirements about where the screen boundary sits, and that is why embedding can so often be avoided. We suggest going through these options in order.

  1. Move the boundary to a screen edge. If a native screen needs one Flutter component, ask whether the whole screen can become Flutter, or whether the component can stay native for now. A screen that is entirely one technology has none of the problems described above. The same goes the other way: if a Flutter screen needs a native control that is just UI, rewriting the control in Flutter is often cheaper than embedding it.
  2. Overlay instead of embed. A Flutter component that appears over a native screen - a bottom sheet, a picker, a confirmation - does not have to be inside it. Inlay's dialog routes render Flutter in a transparent full-screen container on top of the native screen, which gives you Flutter content on a native screen without any sizing or gesture coordination. The native side can do the same over Flutter through a native route.
  3. Keep the SDK native, render in Flutter. When the native dependency is logic rather than pixels - a payments SDK returning a token, a recommendations engine, analytics - wrap it behind a Pigeon API and draw the UI in Flutter. This does not work for SDKs whose contract is their view (ad SDKs measuring viewability, DRM video players, some maps), and that is exactly the category where embedding tends to be unavoidable.
  4. Accept the benign case: full-size views side by side. A native tab bar whose tabs are each a full-size Flutter container, or a split layout with one Flutter pane, is technically embedding - several engines on one screen - but none of the hard parts apply. The frames are fixed, nothing scrolls inside anything, and the lifecycles are clean. If your case reduces to this, the engine-per-view cost is the only thing to budget for.
  5. Embed, and treat it as a project. What remains is the small set of cases where a third-party view has to live inside the other technology's screen. Budget for the sizing and gesture work, register factories per engine, pick the composition mode you need on Android and measure it, and keep the embedded area as small and as static as you can.

Rules of thumb

  • Avoid embedding when you can. Every team we talked to that did a lot of it would do less of it next time.
  • Every embedded view is extra work on top of the engine it costs - sizing, gestures, and lifecycle each need a solution of their own. Count that work before you decide.
  • Prefer overlays to partial views. A Flutter dialog or sheet over a native screen covers most "Flutter on a native screen" requirements with none of the composition problems.
  • Use content-sized views (Flutter 3.41+) for anything that is not a fixed frame, and keep ListView, LayoutBuilder, and Scaffold out of the embedded root.
  • Register platform view factories per engine in setOnEngineCreated, and expect the native objects behind them to be created and destroyed together with each container.
  • On Android, measure before you pick a composition mode. Texture Layer is the default and is enough in most cases; Hybrid Composition merges threads and costs frames.
  • Don't put Flutter surfaces in recycled list rows. If that is what the design needs, the boundary belongs somewhere else.

On this page