Perché le Platform Views
Flutter disegna ogni pixel della propria interfaccia con il suo motore di rendering: i widget non sono view native, sono primitive grafiche composte su una canvas. Questo è il punto di forza del framework, ma crea un problema quando serve mostrare un componente che esiste solo in forma nativa: un SDK di mappe proprietario, un player DRM, un lettore di documenti, un controllo di firma digitale, un widget di un provider di pagamenti o un WebView.
La soluzione si chiama Platform View: un meccanismo che permette di incorporare una android.view.View o una UIView all'interno dell'albero dei widget Flutter, come se fosse un widget qualsiasi. È lo stesso meccanismo su cui sono costruiti pacchetti come webview_flutter e google_maps_flutter.
In questa guida vediamo come funziona davvero, come implementare una platform view da zero su Android e iOS, come gestire gesti e comunicazione bidirezionale e — soprattutto — quali costi comporta.
Quando usarle (e quando evitarle)
Le platform views sono potenti ma costose: ogni view nativa rompe la pipeline di rendering di Flutter e obbliga il motore a comporre il frame insieme al sistema operativo. Prima di usarle chiediti:
- Posso ricostruire la UI in Flutter? Nel 90% dei casi sì, con risultati migliori in termini di performance e coerenza visiva.
- Mi serve solo logica nativa, non UI? Allora usa un platform channel (meglio se generato con Pigeon) o FFI: non serve una platform view.
- Mi serve solo un flusso di pixel (es. frame di una camera o di un decoder video)? Il widget
Textureè molto più efficiente: il nativo scrive su unaSurfaceTexture/CVPixelBuffere Flutter la compone come texture GPU, senza interleaving di view.
Le platform views restano la scelta giusta quando il componente nativo gestisce da sé input, scroll, accessibilità e rendering complesso — tipicamente un SDK di terze parti che non puoi riscrivere.
Le modalità di composizione su Android
Su Android esistono tre strategie, con trade-off diversi. Conoscerle serve a capire perché a volte la tastiera, i gesti o le trasformazioni non si comportano come previsto.
Hybrid Composition (HC)
La view nativa viene aggiunta davvero alla gerarchia di view dell'Activity. Input, tastiera e accessibilità funzionano in modo nativo e perfetto. Il prezzo è che Flutter deve convertire la propria UI in una FlutterImageView quando una platform view è presente, con un costo di sincronizzazione tra thread UI e raster. API: PlatformViewsService.initExpensiveAndroidView.
Texture Layer Hybrid Composition (TLHC)
La view nativa viene renderizzata su una texture che Flutter compone nella propria scena. È la modalità consigliata e di default nelle versioni recenti del framework: costa molto meno di HC e mantiene buona parte dei vantaggi. Alcune view "esotiche" (quelle che disegnano su SurfaceView propri o usano hardware overlay) non sono compatibili e il framework ricade automaticamente su Hybrid Composition. API: PlatformViewsService.initSurfaceAndroidView.
Virtual Display (VD)
La modalità storica: la view viene renderizzata in un display virtuale off-screen e il risultato è copiato in una texture. Funziona ovunque ma ha problemi noti con tastiera, accessibilità e gestione del testo. API: PlatformViewsService.initAndroidView. Va considerata solo come fallback.
Su iOS il discorso è più semplice: esiste una sola modalità, basata su hybrid composition, esposta dal widget UiKitView. Il flag io.flutter.embedded_views_preview nell'Info.plist, richiesto dalle vecchie versioni, non serve più da anni.
Il lato Dart: AndroidView, UiKitView e PlatformViewLink
La versione più semplice usa direttamente i widget AndroidView e UiKitView:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class NativeBadgeView extends StatelessWidget {
const NativeBadgeView({super.key, required this.text});
final String text;
static const String viewType = 'it.example/native_badge';
@override
Widget build(BuildContext context) {
final creationParams = <String, dynamic>{'text': text};
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return AndroidView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
);
case TargetPlatform.iOS:
return UiKitView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
);
default:
return const SizedBox.shrink();
}
}
}
Se vuoi controllo esplicito sulla modalità di composizione su Android, usa PlatformViewLink:
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
Widget buildAndroidBadge(Map<String, dynamic> creationParams) {
return PlatformViewLink(
viewType: NativeBadgeView.viewType,
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
);
},
onCreatePlatformView: (params) {
return PlatformViewsService.initSurfaceAndroidView(
id: params.id,
viewType: NativeBadgeView.viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
onFocus: () => params.onFocusChanged(true),
)
..addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
..create();
},
);
}
Sostituendo initSurfaceAndroidView con initExpensiveAndroidView ottieni Hybrid Composition pura: utile se la view nativa usa un SurfaceView interno (player video, renderer OpenGL) e con TLHC vedi artefatti o schermate nere.
Il lato Android: factory e PlatformView
Su Android devi fornire una PlatformViewFactory e registrarla con un identificativo di tipo (viewType) identico a quello usato in Dart.
import android.content.Context
import android.view.View
import android.widget.TextView
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class NativeBadgeFactory(
private val messenger: BinaryMessenger,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
@Suppress("UNCHECKED_CAST")
val params = args as? Map<String, Any?> ?: emptyMap()
return NativeBadgeView(context, viewId, params, messenger)
}
}
class NativeBadgeView(
context: Context,
viewId: Int,
params: Map<String, Any?>,
messenger: BinaryMessenger,
) : PlatformView, MethodChannel.MethodCallHandler {
private val textView = TextView(context).apply {
text = params["text"] as? String ?: ""
textSize = 18f
}
private val channel = MethodChannel(messenger, "it.example/native_badge_$viewId")
init {
channel.setMethodCallHandler(this)
textView.setOnClickListener { channel.invokeMethod("onTap", null) }
}
override fun getView(): View = textView
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"setText" -> {
textView.text = call.argument<String>("text").orEmpty()
result.success(null)
}
else -> result.notImplemented()
}
}
override fun dispose() {
channel.setMethodCallHandler(null)
}
}
La registrazione avviene nel plugin (o nella MainActivity, se la view vive solo nell'app):
class NativeBadgePlugin : FlutterPlugin {
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
binding.platformViewRegistry.registerViewFactory(
"it.example/native_badge",
NativeBadgeFactory(binding.binaryMessenger),
)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) = Unit
}
Attenzione al dispose(): è il punto in cui devi rilasciare listener, canali e risorse native. Dimenticarlo è una delle cause più comuni di memory leak nelle app che usano platform views in liste.
Il lato iOS: FlutterPlatformViewFactory
import Flutter
import UIKit
class NativeBadgeFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func create(withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?) -> FlutterPlatformView {
NativeBadgeView(frame: frame, viewId: viewId, args: args, messenger: messenger)
}
func createArgsCodec() -> (FlutterMessageCodec & NSObjectProtocol)? {
FlutterStandardMessageCodec.sharedInstance()
}
}
class NativeBadgeView: NSObject, FlutterPlatformView {
private let label: UILabel
private let channel: FlutterMethodChannel
init(frame: CGRect, viewId: Int64, args: Any?, messenger: FlutterBinaryMessenger) {
label = UILabel(frame: frame)
channel = FlutterMethodChannel(
name: "it.example/native_badge_\(viewId)",
binaryMessenger: messenger
)
super.init()
if let params = args as? [String: Any] {
label.text = params["text"] as? String
}
label.font = .systemFont(ofSize: 18)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "setText" else { return result(FlutterMethodNotImplemented) }
let args = call.arguments as? [String: Any]
self?.label.text = args?["text"] as? String
result(nil)
}
}
func view() -> UIView { label }
}
E la registrazione nel plugin:
public static func register(with registrar: FlutterPluginRegistrar) {
let factory = NativeBadgeFactory(messenger: registrar.messenger())
registrar.register(factory, withId: "it.example/native_badge")
}
Nota il viewId nel nome del canale: avere un canale per istanza evita di confondere i messaggi quando la stessa platform view compare più volte nella schermata.
Controllare la view dal lato Dart
Una volta creata la view, usa il callback onPlatformViewCreated per costruire il controller Dart:
class NativeBadgeController {
NativeBadgeController(int viewId)
: _channel = MethodChannel('it.example/native_badge_$viewId');
final MethodChannel _channel;
Future<void> setText(String text) =>
_channel.invokeMethod<void>('setText', {'text': text});
void onTap(VoidCallback callback) {
_channel.setMethodCallHandler((call) async {
if (call.method == 'onTap') callback();
});
}
}
Per progetti reali conviene generare questi canali con Pigeon, ottenendo API tipizzate e nessun errore di battitura nei nomi dei metodi.
Gesti: chi vince il tocco?
Per impostazione predefinita, l'arena dei gesti di Flutter può "rubare" i tocchi alla view nativa: se la platform view si trova dentro un ListView o un PageView, lo scroll di Flutter ha la precedenza. Puoi cambiare questo comportamento passando dei gesture recognizer:
UiKitView(
viewType: NativeBadgeView.viewType,
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
Factory<OneSequenceGestureRecognizer>(() => EagerGestureRecognizer()),
},
)
EagerGestureRecognizer fa vincere subito la view nativa: tutti i tocchi arrivano al componente nativo. Se invece vuoi delegare solo gesti specifici, inserisci i recognizer corrispondenti (VerticalDragGestureRecognizer, ScaleGestureRecognizer, ecc.). È il modo corretto per far convivere, ad esempio, una mappa nativa con pinch-to-zoom dentro una pagina scrollabile.
Insidie e buone pratiche
- Non metterle in liste lunghe. Ogni platform view comporta overhead di composizione. Dieci
WebViewdentro unaListView.buildersono una ricetta per i jank. - Trasformazioni e opacità limitate. Rotazioni,
Opacity,ClipPathcomplessi eBackdropFilterapplicati sopra una platform view possono non essere rispettati o costare molto. Progetta la UI in modo che la view nativa sia un rettangolo piatto. - Golden test impossibili. Le platform views non vengono renderizzate nei test di widget: nei golden test appaiono come aree vuote. Isolale dietro un'astrazione e sostituiscile con un placeholder nei test.
- Attenzione al ciclo di vita. Rilascia le risorse native in
dispose()lato nativo e cancella gli handler dei canali lato Dart, altrimenti i leak si accumulano a ogni navigazione. - Verifica con Impeller. Il motore di rendering corrente gestisce le platform views, ma alcune combinazioni con
SurfaceViewo effetti grafici possono comportarsi diversamente rispetto a Skia: testa su dispositivi reali, non solo sull'emulatore. - Accessibilità. Con TLHC e HC l'albero di semantica nativo viene unito a quello di Flutter; con Virtual Display no. È un altro motivo per evitare la modalità VD.
In sintesi
Le platform views sono la via d'uscita quando un SDK nativo non ha equivalenti in Flutter. Il pattern è sempre lo stesso: una factory registrata con un viewType, un widget AndroidView/UiKitView (o PlatformViewLink per il controllo fine della composizione) e un MethodChannel per istanza per la comunicazione bidirezionale.
Usate con parsimonia, risolvono problemi altrimenti bloccanti. Usate come scorciatoia per non riscrivere la UI, diventano rapidamente il collo di bottiglia delle performance della tua app: prima di aggiungerne una, chiediti sempre se un widget Flutter, un Texture o un semplice platform channel non siano la risposta migliore.