When you embed Flutter into an existing iOS app, one common pattern is to place a FlutterViewController on top of native UIKit content as an overlay. Visually, this is straightforward: make the Flutter view transparent and render only the pieces of UI you want to show.
The tricky part is touch handling.
Even when the Flutter overlay is visually transparent, it still sits on top of the native view hierarchy. That means touches inside the overlay’s frame are usually captured by the Flutter view instead of reaching the UIViews underneath. If your design expects native controls below the transparent parts of Flutter to remain tappable, visual transparency alone is not enough.
This post explains the problem, the recommended solution, and a sample implementation for both iOS and Flutter.
Suppose your app looks like this:
- a native iOS screen provides the main content,
- a
FlutterViewControlleris embedded above it, - the Flutter view has a transparent background,
- Flutter only draws a few floating controls,
- everything else appears visually empty.
From the user’s perspective, it is natural to expect that tapping on the transparent area should interact with the native views underneath.
But that is not how UIKit works.
A UIView participates in hit testing based on its presence in the view hierarchy, its bounds, visibility, and interaction settings. UIKit does not decide hit testing based on whether a particular pixel is visually transparent.
So even if Flutter paints nothing in a large area, the overlay still exists as a full-screen view. Taps inside that frame can still be claimed by the Flutter side instead of falling through to native UIKit below.
That creates a mismatch:
- visually the area looks empty,
- logically the overlay still owns the touch region.
The cleanest solution is to treat this as a region-based hit-testing problem.
Instead of asking iOS to infer which Flutter pixels are visually transparent, define exactly which parts of the Flutter overlay should be interactive. Then make the native overlay container pass through all other touches.
In practice, the architecture looks like this:
- The iOS app embeds
FlutterViewController.viewinside a custom container view. - That container overrides hit testing so only specific rectangles are considered interactive.
- Flutter reports the rectangles of its interactive widgets to iOS through a platform channel.
- Taps inside those rectangles go to Flutter.
- Taps everywhere else pass through to the native
UIViews underneath.
This approach is predictable, efficient, and works well with Flutter’s add-to-app model.
Here is the flow:
- Flutter renders an overlay UI.
- Certain widgets are designated as “native touch regions.”
- After layout, Flutter measures those widgets and computes their rects relative to the Flutter overlay root.
- Flutter sends the rect list to iOS.
- iOS stores those rects in a custom pass-through container.
- The container returns
truefor touch points inside those rects andfalseotherwise.
That means:
- Flutter still renders normally.
- Native UIKit still handles underlying controls outside the Flutter interaction zones.
- The two layers cooperate without fighting UIKit’s hit-testing model.
This container decides whether a touch should be handled by Flutter or passed through to whatever is underneath.
import UIKit
final class FlutterPassThroughContainerView: UIView {
/// Rectangles, in this view's coordinate space, that should be routed to Flutter.
var interactiveRects: [CGRect] = []
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return interactiveRects.contains { $0.contains(point) }
}
}This is the core idea.
If a touch lands inside one of the known Flutter interaction regions, the container participates in hit testing and Flutter can receive the event.
If not, point(inside:with:) returns false, and UIKit continues searching down the view hierarchy for a native view underneath.
Now place the FlutterViewController.view inside that pass-through container.
import UIKit
import Flutter
final class HostViewController: UIViewController {
private let flutterEngine: FlutterEngine
private let flutterViewController: FlutterViewController
private let overlayView = FlutterPassThroughContainerView()
init(engine: FlutterEngine) {
self.flutterEngine = engine
self.flutterViewController = FlutterViewController(
engine: engine,
nibName: nil,
bundle: nil
)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
// Make Flutter visually transparent.
flutterViewController.view.backgroundColor = .clear
flutterViewController.view.isOpaque = false
overlayView.backgroundColor = .clear
addChild(flutterViewController)
overlayView.translatesAutoresizingMaskIntoConstraints = false
flutterViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayView)
overlayView.addSubview(flutterViewController.view)
NSLayoutConstraint.activate([
overlayView.topAnchor.constraint(equalTo: view.topAnchor),
overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
flutterViewController.view.topAnchor.constraint(equalTo: overlayView.topAnchor),
flutterViewController.view.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor),
flutterViewController.view.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor),
flutterViewController.view.bottomAnchor.constraint(equalTo: overlayView.bottomAnchor),
])
flutterViewController.didMove(toParent: self)
configureHitRegionChannel()
}
private func configureHitRegionChannel() {
let channel = FlutterMethodChannel(
name: "overlay_hit_regions",
binaryMessenger: flutterViewController.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "setInteractiveRects" else {
result(FlutterMethodNotImplemented)
return
}
guard
let args = call.arguments as? [String: Any],
let rects = args["rects"] as? [[String: Any]]
else {
result(FlutterError(
code: "INVALID_ARGUMENTS",
message: "Expected a map containing 'rects'",
details: nil
))
return
}
self?.overlayView.interactiveRects = rects.compactMap { item in
guard
let x = item["x"] as? CGFloat,
let y = item["y"] as? CGFloat,
let width = item["width"] as? CGFloat,
let height = item["height"] as? CGFloat
else {
return nil
}
return CGRect(x: x, y: y, width: width, height: height)
}
result(nil)
}
}
}At this point, iOS is ready. The only remaining piece is: how do we provide interactiveRects from Flutter?
Rather than attempting to inspect every interactive widget in the widget tree, the most maintainable approach is to explicitly mark the widgets that should remain touchable from the iOS side.
We can do that with a small wrapper widget:
- it wraps an interactive Flutter widget,
- measures its layout rect after a frame,
- reports that rect to a parent manager,
- and the manager sends the aggregated rect list to iOS.
This keeps the solution intentional and easy to reason about.
import 'package:flutter/widgets.dart';
class NativeTouchRegion extends StatefulWidget {
const NativeTouchRegion({
super.key,
required this.id,
required this.overlayRootKey,
required this.onRectChanged,
required this.child,
});
final String id;
final GlobalKey overlayRootKey;
final void Function(String id, Rect rect) onRectChanged;
final Widget child;
@override
State<NativeTouchRegion> createState() => _NativeTouchRegionState();
}
class _NativeTouchRegionState extends State<NativeTouchRegion> {
final GlobalKey _selfKey = GlobalKey();
Rect? _lastReportedRect;
bool _scheduled = false;
void _scheduleMeasurement() {
if (_scheduled) return;
_scheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scheduled = false;
if (!mounted) return;
final selfContext = _selfKey.currentContext;
final rootContext = widget.overlayRootKey.currentContext;
if (selfContext == null || rootContext == null) return;
final selfBox = selfContext.findRenderObject() as RenderBox?;
final rootBox = rootContext.findRenderObject() as RenderBox?;
if (selfBox == null || rootBox == null) return;
if (!selfBox.attached || !rootBox.attached) return;
final topLeft = selfBox.localToGlobal(Offset.zero, ancestor: rootBox);
final rect = topLeft & selfBox.size;
if (_lastReportedRect != rect) {
_lastReportedRect = rect;
widget.onRectChanged(widget.id, rect);
}
});
}
@override
void initState() {
super.initState();
_scheduleMeasurement();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleMeasurement();
}
@override
void didUpdateWidget(covariant NativeTouchRegion oldWidget) {
super.didUpdateWidget(oldWidget);
_scheduleMeasurement();
}
@override
Widget build(BuildContext context) {
_scheduleMeasurement();
return KeyedSubtree(
key: _selfKey,
child: widget.child,
);
}
}This widget measures itself relative to the overlay root, not the screen. That is important because the iOS pass-through container uses the Flutter overlay’s coordinate space.
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class OverlayHitRegionManager extends StatefulWidget {
const OverlayHitRegionManager({
super.key,
required this.childBuilder,
});
final Widget Function(
GlobalKey overlayRootKey,
void Function(String id, Rect rect) updateRegion,
) childBuilder;
@override
State<OverlayHitRegionManager> createState() => _OverlayHitRegionManagerState();
}
class _OverlayHitRegionManagerState extends State<OverlayHitRegionManager> {
static const MethodChannel _channel = MethodChannel('overlay_hit_regions');
final GlobalKey _overlayRootKey = GlobalKey();
final Map<String, Rect> _regions = <String, Rect>{};
Future<void> _pushRegionsToIOS() async {
final payload = _regions.entries.map((entry) {
final rect = entry.value;
return <String, Object>{
'id': entry.key,
'x': rect.left,
'y': rect.top,
'width': rect.width,
'height': rect.height,
};
}).toList();
await _channel.invokeMethod('setInteractiveRects', {
'rects': payload,
});
}
void _updateRegion(String id, Rect rect) {
final existing = _regions[id];
if (existing == rect) return;
_regions[id] = rect;
_pushRegionsToIOS();
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: _overlayRootKey,
child: widget.childBuilder(_overlayRootKey, _updateRegion),
);
}
}This manager gathers every reported region and sends the latest set to iOS.
Now the overlay can define exactly which Flutter controls should receive touches.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({super.key});
@override
Widget build(BuildContext context) {
return OverlayHitRegionManager(
childBuilder: (overlayRootKey, updateRegion) {
return Stack(
children: [
Positioned(
top: 120,
left: 24,
child: NativeTouchRegion(
id: 'primary_button',
overlayRootKey: overlayRootKey,
onRectChanged: updateRegion,
child: GestureDetector(
onTap: () {
debugPrint('Flutter button tapped');
},
child: Container(
width: 160,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Tap me',
style: TextStyle(color: Colors.white),
),
),
),
),
),
Positioned(
top: 220,
right: 24,
child: NativeTouchRegion(
id: 'floating_chip',
overlayRootKey: overlayRootKey,
onRectChanged: updateRegion,
child: GestureDetector(
onTap: () {
debugPrint('Chip tapped');
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Overlay Action',
style: TextStyle(color: Colors.white),
),
),
),
),
),
],
);
},
);
}
}With this setup:
- tapping the button or chip goes to Flutter,
- tapping anywhere else in the transparent overlay falls through to native iOS content below.
UIKit wants a view to answer a simple question: “Should this point be considered inside you?”
The custom container answers that clearly and efficiently by checking whether the point falls inside a known interactive rectangle.
Trying to derive touch behavior from rendered transparency is fragile and platform-specific. Even if it were possible, it would be much harder to maintain.
Using explicit rects keeps the contract between Flutter and native code simple.
This approach works well for:
- floating buttons,
- chips,
- banners,
- draggable handles,
- overlay menus,
- mixed native/Flutter screens.
It also keeps ownership clear:
- Flutter owns layout and visual presentation,
- iOS owns hit-test routing at the container boundary.
The rectangles sent from Flutter must be expressed in the same coordinate space the iOS container expects.
In the sample above, Flutter measures each region relative to the overlay root using:
selfBox.localToGlobal(Offset.zero, ancestor: rootBox)That ensures the reported rects line up with the iOS overlay container.
If your Flutter overlay moves, resizes, animates, scrolls, or changes orientation, the rects need to be updated.
The sample widget schedules measurement after layout and reports changes when the rect differs from the last value. That is usually enough for most overlays.
For highly dynamic overlays, you may want tighter synchronization, but the overall architecture stays the same.
In a production implementation, you may also want NativeTouchRegion to notify the manager when it is disposed so that iOS no longer considers that rect interactive.
For example:
@override
void dispose() {
widget.onRegionRemoved(widget.id);
super.dispose();
}and then have the manager remove that region before pushing the updated list.
A good rule is simple:
- wrap the Flutter widgets that should receive touches,
- do not try to infer all touchable widgets automatically.
That keeps the implementation explicit and avoids surprises.
The transparent Flutter overlay should not try to make iOS understand visual transparency; instead, Flutter should tell iOS exactly which overlay regions are interactive, and iOS should pass all other touches through.
Embedding a transparent FlutterViewController over native iOS content is easy visually, but touch handling requires extra work.
The reliable solution is:
- make Flutter visually transparent,
- embed it inside a custom pass-through
UIView, - override hit testing in that container,
- measure interactive Flutter widgets after layout,
- send their rects to iOS over a platform channel,
- and allow only those regions to receive Flutter touches.
This produces a clean hybrid experience where Flutter and UIKit coexist on the same screen without interfering with each other.