Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save bcse/361ca0778c4d9b81b846c13b36bdb72e to your computer and use it in GitHub Desktop.

Select an option

Save bcse/361ca0778c4d9b81b846c13b36bdb72e to your computer and use it in GitHub Desktop.
Making a Transparent Embedded FlutterView Pass Touches Through on iOS

Making a Transparent Embedded FlutterView Pass Touches Through on iOS

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.

The problem

Suppose your app looks like this:

  • a native iOS screen provides the main content,
  • a FlutterViewController is 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.

Why transparency does not automatically allow touch passthrough

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 recommended solution

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:

  1. The iOS app embeds FlutterViewController.view inside a custom container view.
  2. That container overrides hit testing so only specific rectangles are considered interactive.
  3. Flutter reports the rectangles of its interactive widgets to iOS through a platform channel.
  4. Taps inside those rectangles go to Flutter.
  5. 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.

Architecture overview

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 true for touch points inside those rects and false otherwise.

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.

iOS implementation

1) Create a pass-through container

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.

2) Embed Flutter inside the container

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?


Flutter implementation

The key idea

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.

1) Create a region-reporting widget

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.

2) Create a manager that sends all rects to iOS

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.

3) Wrap the interactive Flutter widgets

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.

Why this solution works well

It matches UIKit’s hit-testing model

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.

It avoids per-pixel complexity

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.

It scales to real product code

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.

Practical considerations

Coordinate systems must match

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.

Update regions after layout changes

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.

Remove stale regions when widgets disappear

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.

Use wrappers intentionally

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.


Full concept in one sentence

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.


Summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment