diff --git a/example/lib/main.dart b/example/lib/main.dart index b7c217a4..453970bb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -35,6 +35,8 @@ import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'full_map_example.dart'; import 'location_example.dart'; import 'example.dart'; +import 'map_texture_example.dart'; +import 'map_texture_perf_example.dart'; import 'point_annotations_example.dart'; import 'projection_example.dart'; import 'rainbow_road_example.dart'; @@ -45,6 +47,8 @@ import 'map_recorder_example.dart'; final List _allPages = [ SimpleMapExample(), + MapTextureExample(), + MapTexturePerfExample(), ViewportExample(), SnapshotterExample(), TrafficRouteLineExample(), diff --git a/example/lib/map_texture_example.dart b/example/lib/map_texture_example.dart new file mode 100644 index 00000000..fa5b688b --- /dev/null +++ b/example/lib/map_texture_example.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; +import 'dart:ui' as ui; +import 'example.dart'; + +/// Shows the difference a texture makes: pressing the button captures this +/// page with `RepaintBoundary.toImage`. With [MapTexture] the capture contains +/// the map, because the map is ordinary flutter content. With a [MapWidget] +/// the same capture comes back with a hole where the map is, because a +/// platform view is composited by uikit and is not in flutter's scene. +class MapTextureExample extends StatefulWidget implements Example { + @override + final Widget leading = const Icon(Icons.photo_camera_back); + @override + final String title = 'Map in a flutter texture (iOS)'; + @override + final String? subtitle = 'No platform view, so the map can be captured'; + + @override + State createState() => MapTextureExampleState(); +} + +class MapTextureExampleState extends State { + final GlobalKey _boundary = GlobalKey(); + ui.Image? _capture; + + Future _onMapCreated(MapboxMap map) async { + await map.setCamera( + CameraOptions( + center: Point(coordinates: Position(-0.0880, 51.5140)), + zoom: 12.5, + ), + ); + } + + Future _captureThePage() async { + final object = _boundary.currentContext?.findRenderObject(); + if (object is! RenderRepaintBoundary) return; + final image = await object.toImage(pixelRatio: 1); + if (!mounted) return; + setState(() => _capture = image); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: _captureThePage, + label: const Text('RepaintBoundary.toImage'), + ), + body: Stack( + children: [ + Positioned.fill( + child: RepaintBoundary( + key: _boundary, + child: MapTexture( + styleUri: MapboxStyles.MAPBOX_STREETS, + onMapCreated: _onMapCreated, + ), + ), + ), + if (_capture != null) + Positioned( + right: 18, + bottom: 120, + child: Container( + padding: const EdgeInsets.all(3), + color: Colors.black, + child: ClipRect( + child: SizedBox( + width: 150, + height: 260, + child: ColoredBox( + color: Colors.white, + child: FittedBox( + fit: BoxFit.contain, + child: RawImage(image: _capture), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/map_texture_perf_example.dart b/example/lib/map_texture_perf_example.dart new file mode 100644 index 00000000..829ace7f --- /dev/null +++ b/example/lib/map_texture_perf_example.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; +import 'example.dart'; + +/// Runs the same camera movement against a [MapWidget] and a [MapTexture] and +/// reports what flutter's own instrumentation saw, so the cost of the texture +/// path can be compared rather than argued about. +/// +/// Frame times come from `SchedulerBinding.addTimingsCallback`, which is the +/// engine's own measurement, not a stopwatch around the call. +class MapTexturePerfExample extends StatefulWidget implements Example { + @override + final Widget leading = const Icon(Icons.speed); + @override + final String title = 'Map texture vs platform view (perf)'; + @override + final String? subtitle = 'Same camera path, frame times from the engine'; + + @override + State createState() => MapTexturePerfExampleState(); +} + +class MapTexturePerfExampleState extends State { + bool _useTexture = false; + bool _running = false; + MapboxMap? _map; + + final List _buildMicros = []; + final List _rasterMicros = []; + String _result = ''; + + void _onTimings(List timings) { + if (!_running) return; + for (final timing in timings) { + _buildMicros.add(timing.buildDuration.inMicroseconds); + _rasterMicros.add(timing.rasterDuration.inMicroseconds); + } + } + + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addTimingsCallback(_onTimings); + } + + @override + void dispose() { + SchedulerBinding.instance.removeTimingsCallback(_onTimings); + super.dispose(); + } + + Future _run() async { + final map = _map; + if (map == null || _running) return; + _buildMicros.clear(); + _rasterMicros.clear(); + setState(() { + _running = true; + _result = 'running'; + }); + + // a fixed path, so both modes do identical work + for (var i = 0; i < 40; i++) { + await map.setCamera(CameraOptions( + center: Point( + coordinates: Position(-0.0880 + i * 0.0015, 51.5140 + i * 0.0008), + ), + zoom: 12.5 + (i % 10) * 0.05, + bearing: i * 3.0, + )); + await Future.delayed(const Duration(milliseconds: 50)); + } + + _running = false; + setState(() => _result = _summary()); + } + + String _summary() { + if (_buildMicros.isEmpty) return 'no frames'; + List sorted(List v) => List.from(v)..sort(); + int percentile(List v, double p) => + sorted(v)[((v.length - 1) * p).round()]; + String ms(int micros) => (micros / 1000).toStringAsFixed(2); + + final mode = _useTexture ? 'MapTexture' : 'MapWidget'; + return '$mode over ${_buildMicros.length} frames\n' + 'build p50 ${ms(percentile(_buildMicros, 0.5))} ms ' + 'p90 ${ms(percentile(_buildMicros, 0.9))} ms\n' + 'raster p50 ${ms(percentile(_rasterMicros, 0.5))} ms ' + 'p90 ${ms(percentile(_rasterMicros, 0.9))} ms'; + } + + Future _onMapCreated(MapboxMap map) async { + _map = map; + await map.setCamera( + CameraOptions( + center: Point(coordinates: Position(-0.0880, 51.5140)), + zoom: 12.5, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: _useTexture + ? MapTexture( + key: const ValueKey('texture'), + styleUri: MapboxStyles.MAPBOX_STREETS, + onMapCreated: _onMapCreated, + ) + : MapWidget( + key: const ValueKey('platform'), + styleUri: MapboxStyles.MAPBOX_STREETS, + onMapCreated: _onMapCreated, + ), + ), + Positioned( + left: 12, + right: 12, + top: 60, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: Colors.black, + padding: const EdgeInsets.all(10), + child: Text( + _result.isEmpty ? 'pick a mode, then run' : _result, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontFamily: 'monospace'), + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _running + ? null + : () { + _map = null; + setState(() { + _useTexture = false; + _result = ''; + }); + }, + child: const Text('MapWidget'), + ), + ElevatedButton( + onPressed: _running + ? null + : () { + _map = null; + setState(() { + _useTexture = true; + _result = ''; + }); + }, + child: const Text('MapTexture'), + ), + ElevatedButton( + onPressed: _running ? null : _run, + child: const Text('run'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/HeadlessMapTexture.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/HeadlessMapTexture.swift new file mode 100644 index 00000000..dc36d729 --- /dev/null +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/HeadlessMapTexture.swift @@ -0,0 +1,207 @@ +import Flutter +import MapboxMaps +import MetalKit +import UIKit + +/// A map that is not a platform view. +/// +/// The plugin's only way to show a map on ios is `MapboxMapController`, a +/// `FlutterPlatformView`. UIKit composites that view, not flutter, so nothing +/// drawn above it can read it: backdrop filters, shaders and captures all come +/// back empty. Android does not have this problem because it can render the +/// map into a `TextureView`. +/// +/// This is that mode for ios. The same `MapboxMapController` is built, so every +/// pigeon api the normal map exposes is available on the same channel suffix, +/// but its view is parked offscreen and its frames go to +/// `FlutterTextureRegistry`. The widget tree gets a `Texture` and no platform +/// view at all. +final class HeadlessMapTexture: NSObject { + private static var instances: [Int64: HeadlessMapTexture] = [:] + + private let host: UIView + private let controller: MapboxMapController + private let publisher: MapTexturePublisher + private let textureId: Int64 + + private init?(size: CGSize, + channelSuffix: Int, + options: MapInitOptions, + registrar: FlutterPluginRegistrar) { + let frame = CGRect(origin: .zero, size: size) + + // The real controller, so style, camera, annotations, gestures and + // every other pigeon api work exactly as they do for the platform + // view. Only where its view lives is different. + controller = MapboxMapController( + withFrame: frame, + mapInitOptions: options, + channelSuffix: channelSuffix, + registrar: registrar, + pluginVersion: "", + eventTypes: [] + ) + + // Parked in the app's OWN key window, off to the side, rather than in + // a window of our own. A second UIWindow steals the scene and the + // flutter view goes to the background. Offscreen inside the existing + // window keeps CoreAnimation compositing the layer, which is what + // keeps the drawable pool recycling. + guard let key = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow }) else { return nil } + host = UIView(frame: CGRect(x: -size.width * 4, y: 0, + width: size.width, height: size.height)) + host.isUserInteractionEnabled = false + host.addSubview(controller.view()) + key.addSubview(host) + + publisher = MapTexturePublisher(mapView: controller.view(), + textures: registrar.textures()) + let id = publisher.start() + guard id >= 0 else { return nil } + textureId = id + super.init() + } + + static func create(size: CGSize, + channelSuffix: Int, + options: MapInitOptions, + registrar: FlutterPluginRegistrar) -> Int64 { + guard let instance = HeadlessMapTexture(size: size, + channelSuffix: channelSuffix, + options: options, + registrar: registrar) else { + return -1 + } + instances[instance.textureId] = instance + return instance.textureId + } + + static func dispose(textureId: Int64) { + instances[textureId]?.publisher.stop() + instances[textureId]?.host.removeFromSuperview() + instances[textureId] = nil + } + + /// The logo and attribution are UIKit subviews of the MapView, drawn with + /// Core Graphics, so they are not in the Metal drawable and do not reach + /// the texture. Mapbox's terms require them, so they are rasterised here + /// and drawn by flutter over the texture at the same position. + /// + /// Returns a transparent image the size of the map with only the ornaments + /// in it, so the caller can overlay it without covering the map. + static func ornaments(textureId: Int64) -> FlutterStandardTypedData? { + guard let instance = instances[textureId] else { return nil } + let view = instance.controller.view() + let ornaments = view.subviews.filter { !($0 is MTKView) && !$0.isHidden } + guard !ornaments.isEmpty else { return nil } + + let format = UIGraphicsImageRendererFormat.default() + format.opaque = false + let renderer = UIGraphicsImageRenderer(bounds: view.bounds, format: format) + let image = renderer.image { _ in + for ornament in ornaments { + let frame = ornament.convert(ornament.bounds, to: view) + ornament.drawHierarchy(in: frame, afterScreenUpdates: true) + } + } + guard let png = image.pngData() else { return nil } + return FlutterStandardTypedData(bytes: png) + } + + /// The map only draws on demand, so a still map stops vending frames. + static func pump(textureId: Int64) { + instances[textureId]?.controller.map.triggerRepaint() + } + + /// Rotation, split view, a keyboard appearing: the texture has to follow + /// the widget or the map renders at one size and is sampled at another. + static func resize(textureId: Int64, size: CGSize) { + guard let instance = instances[textureId] else { return } + guard size.width > 0, size.height > 0 else { return } + instance.host.frame = CGRect(x: -size.width * 4, y: 0, + width: size.width, height: size.height) + instance.controller.view().frame = CGRect(origin: .zero, size: size) + instance.controller.view().layoutIfNeeded() + instance.controller.map.triggerRepaint() + } + + // MARK: - Gestures + // + // The map's view is offscreen, so UIKit will never deliver touches to it + // and its own recognisers can never fire. Flutter owns the hit test now, + // so it forwards the points and the camera is driven directly. Same maths + // the sdk's own pan recogniser uses, via dragCameraOptions. + + private var lastDrag: CGPoint? + + static func panBegin(textureId: Int64, at point: CGPoint) { + instances[textureId]?.lastDrag = point + } + + static func panUpdate(textureId: Int64, to point: CGPoint) { + guard let instance = instances[textureId], + let from = instance.lastDrag else { return } + let map = instance.controller.map + map.setCamera(to: map.dragCameraOptions(from: from, to: point)) + instance.lastDrag = point + } + + static func panEnd(textureId: Int64) { + instances[textureId]?.lastDrag = nil + } + + /// Two finger twist. Bearing is degrees clockwise from north, the gesture + /// gives radians, and a clockwise twist should turn the map the other way, + /// hence the negation. + static func rotateBy(textureId: Int64, radians: Double, at point: CGPoint) { + guard let instance = instances[textureId] else { return } + let map = instance.controller.map + map.setCamera(to: camera(map, + anchor: point, + bearing: map.cameraState.bearing - radians * 180 / .pi)) + } + + /// Two finger drag up tilts the camera over. Clamped to the sdk's own + /// ceiling; past it the horizon enters the frame and the map is unusable. + static func pitchBy(textureId: Int64, delta: Double) { + guard let instance = instances[textureId] else { return } + let map = instance.controller.map + let pitch = max(0, min(85, map.cameraState.pitch + delta)) + map.setCamera(to: camera(map, pitch: pitch)) + } + + /// Every field, every time. + /// + /// `CameraOptions` is not a patch: a nil field is not "leave this alone", + /// it is "no value", and the camera resolves it to a default. Setting only + /// bearing therefore threw away the centre and the zoom and left a black + /// map. Read the current state and change the one thing. + /// + /// MapboxMaps.CameraOptions, not the pigeon type of the same name that + /// this module also declares. Unqualified it resolves to ours. + private static func camera(_ map: MapboxMap, + anchor: CGPoint? = nil, + zoom: CGFloat? = nil, + bearing: CLLocationDirection? = nil, + pitch: CGFloat? = nil) -> MapboxMaps.CameraOptions { + let state = map.cameraState + return MapboxMaps.CameraOptions( + center: state.center, + padding: state.padding, + anchor: anchor, + zoom: zoom ?? state.zoom, + bearing: bearing ?? state.bearing, + pitch: pitch ?? state.pitch + ) + } + + static func zoomBy(textureId: Int64, delta: Double, at point: CGPoint) { + guard let instance = instances[textureId] else { return } + let map = instance.controller.map + let zoom = max(0, min(22, map.cameraState.zoom + delta)) + map.setCamera(to: camera(map, anchor: point, zoom: zoom)) + } +} diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapTexturePublisher.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapTexturePublisher.swift new file mode 100644 index 00000000..c2d47f82 --- /dev/null +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapTexturePublisher.swift @@ -0,0 +1,199 @@ +import Flutter +import MetalKit +import ObjectiveC.runtime + +/// Publishes an `MTKView`'s frames as a flutter texture. +/// +/// The map draws into its own drawable as usual. Each finished frame is +/// blitted into an IOSurface-backed `CVPixelBuffer` and handed to +/// `FlutterTextureRegistry`, so flutter can composite the map like any other +/// widget rather than as a platform view. +/// +/// Two details are load-bearing: +/// +/// `framebufferOnly = false` on the view, or the drawable's texture cannot be +/// used as a blit source and every copy fails. +/// +/// The frame that is copied is the PREVIOUS one, not the one just handed over. +/// There is no completion callback for a drawable on this sdk, and the current +/// frame has not finished rendering when the draw call returns, while the one +/// before it provably has. The cost is one frame of latency. +final class MapTexturePublisher: NSObject, FlutterTexture { + private struct Entry { + let buffer: CVPixelBuffer + let cvTexture: CVMetalTexture + let texture: MTLTexture + } + + private static var swizzled = Set() + private static let registry = NSMapTable + .weakToWeakObjects() + private static var presentHooked = false + + private let textures: FlutterTextureRegistry + private weak var mapView: UIView? + private weak var mtkView: MTKView? + + private var textureId: Int64 = -1 + private var queue: MTLCommandQueue? + private var textureCache: CVMetalTextureCache? + private var ring: [Entry] = [] + private var ringIndex = 0 + private var ringWidth = 0 + private var ringHeight = 0 + private let lock = NSLock() + private var latest: CVPixelBuffer? + + init(mapView: UIView, textures: FlutterTextureRegistry) { + self.mapView = mapView + self.textures = textures + super.init() + } + + func start() -> Int64 { + if textureId >= 0 { return textureId } + guard let mapView, let mtk = Self.findMTKView(in: mapView) else { return -1 } + mtkView = mtk + mtk.framebufferOnly = false + queue = mtk.device?.makeCommandQueue() + guard let layer = mtk.layer as? CAMetalLayer else { return -1 } + Self.registry.setObject(self, forKey: layer) + Self.hookPresentIfNeeded(device: mtk.device) + textureId = textures.register(self) + return textureId + } + + func stop() { + if let layer = mtkView?.layer as? CAMetalLayer { + Self.registry.removeObject(forKey: layer) + } + if textureId >= 0 { textures.unregisterTexture(textureId) } + textureId = -1 + ring.removeAll() + latest = nil + } + + func copyPixelBuffer() -> Unmanaged? { + lock.lock() + defer { lock.unlock() } + guard let latest else { return nil } + return Unmanaged.passRetained(latest) + } + + // MARK: - Frame capture + + /// Frames are caught at `-[MTLCommandBuffer presentDrawable:]`. + /// + /// `UIView.draw(_:)` is the obvious hook and it never runs: an MTKView + /// renders through Metal, not Core Graphics, so the method is simply not + /// called. Every frame does pass through presentDrawable, and hooking + /// there also means the copy can be encoded onto the SAME command buffer + /// that presents it, so the blit is ordered after the render with no + /// waiting and no guessing. + private static func hookPresentIfNeeded(device: MTLDevice?) { + guard !presentHooked, let device, + let queue = device.makeCommandQueue(), + let probe = queue.makeCommandBuffer() else { return } + presentHooked = true + let cls: AnyClass = type(of: probe) + let sel = NSSelectorFromString("presentDrawable:") + guard let method = class_getInstanceMethod(cls, sel) else { return } + typealias PresentIMP = @convention(c) (AnyObject, Selector, AnyObject) -> Void + let original = unsafeBitCast(method_getImplementation(method), to: PresentIMP.self) + let block: @convention(block) (AnyObject, AnyObject) -> Void = { buffer, drawable in + if let metalDrawable = drawable as? CAMetalDrawable, + let publisher = MapTexturePublisher.registry.object(forKey: metalDrawable.layer), + let commandBuffer = buffer as? MTLCommandBuffer { + publisher.encodeCopy(of: metalDrawable.texture, on: commandBuffer) + } + original(buffer, sel, drawable) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + } + + private func encodeCopy(of source: MTLTexture, on commandBuffer: MTLCommandBuffer) { + guard textureId >= 0, !source.isFramebufferOnly else { return } + guard source.pixelFormat == .bgra8Unorm + || source.pixelFormat == .bgra8Unorm_srgb else { return } + guard let device = mtkView?.device, + let entry = nextEntry(width: source.width, height: source.height, + format: source.pixelFormat, device: device), + let blit = commandBuffer.makeBlitCommandEncoder() else { return } + blit.copy(from: source, sourceSlice: 0, sourceLevel: 0, + sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), + sourceSize: MTLSize(width: source.width, + height: source.height, depth: 1), + to: entry.texture, destinationSlice: 0, destinationLevel: 0, + destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0)) + blit.endEncoding() + commandBuffer.addCompletedHandler { [weak self] _ in + guard let self, self.textureId >= 0 else { return } + self.lock.lock() + self.latest = entry.buffer + self.lock.unlock() + self.textures.textureFrameAvailable(self.textureId) + } + } + + // MARK: - Buffers + + /// Three buffers, so the one flutter is reading is never the one being + /// written. Fewer and the texture tears under a fast camera. + private func nextEntry(width: Int, height: Int, format: MTLPixelFormat, + device: MTLDevice) -> Entry? { + if width != ringWidth || height != ringHeight { + ring.removeAll() + ringWidth = width + ringHeight = height + } + if ring.count < 3 { + guard let entry = makeEntry(width: width, height: height, + format: format, device: device) else { return nil } + ring.append(entry) + return entry + } + ringIndex = (ringIndex + 1) % ring.count + let candidate = ring[ringIndex] + lock.lock() + let inUse = candidate.buffer === latest + lock.unlock() + if inUse { + ringIndex = (ringIndex + 1) % ring.count + return ring[ringIndex] + } + return candidate + } + + private func makeEntry(width: Int, height: Int, format: MTLPixelFormat, + device: MTLDevice) -> Entry? { + if textureCache == nil { + CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache) + } + guard let cache = textureCache else { return nil } + let attributes: [CFString: Any] = [ + kCVPixelBufferMetalCompatibilityKey: true, + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, + ] + var pixelBuffer: CVPixelBuffer? + guard CVPixelBufferCreate(kCFAllocatorDefault, width, height, + kCVPixelFormatType_32BGRA, + attributes as CFDictionary, + &pixelBuffer) == kCVReturnSuccess, + let buffer = pixelBuffer else { return nil } + var metalTexture: CVMetalTexture? + guard CVMetalTextureCacheCreateTextureFromImage( + kCFAllocatorDefault, cache, buffer, nil, format, + width, height, 0, &metalTexture) == kCVReturnSuccess, + let cvTexture = metalTexture, + let texture = CVMetalTextureGetTexture(cvTexture) else { return nil } + return Entry(buffer: buffer, cvTexture: cvTexture, texture: texture) + } + + private static func findMTKView(in view: UIView) -> MTKView? { + if let mtk = view as? MTKView { return mtk } + for subview in view.subviews { + if let found = findMTKView(in: subview) { return found } + } + return nil + } +} diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift index d5dfa210..2cfb6bfc 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift @@ -11,6 +11,10 @@ struct SuffixBinaryMessenger { public final class MapboxMapController: NSObject, FlutterPlatformView { private let mapView: MapView private let mapboxMap: MapboxMap + + /// The underlying map, for callers that host this controller outside the + /// platform view (see HeadlessMapTexture). + var map: MapboxMap { mapboxMap } private let channel: FlutterMethodChannel private let annotationController: AnnotationController? private let gesturesController: GesturesController? diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift index 42b27b5e..96aa37f6 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift @@ -7,9 +7,86 @@ public class MapboxMapsPlugin: NSObject, FlutterPlugin { let instance = MapboxMapFactory(withRegistrar: registrar) registrar.register(instance, withId: "plugins.flutter.io/mapbox_maps") + setupHeadlessChannel(with: registrar) setupStaticChannels(with: registrar) } + private static func setupHeadlessChannel(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "plugins.flutter.io/mapbox_maps_headless", + binaryMessenger: registrar.messenger()) + channel.setMethodCallHandler { call, result in + let args = call.arguments as? [String: Any] ?? [:] + switch call.method { + case "create": + let width = args["width"] as? Double ?? 0 + let height = args["height"] as? Double ?? 0 + guard width > 0, height > 0 else { + result(FlutterError(code: "bad_size", + message: "width and height are required", + details: nil)) + return + } + var options = MapInitOptions() + if let uri = args["styleUri"] as? String { + options = MapInitOptions(styleURI: StyleURI(rawValue: uri)) + } + let suffix = (args["channelSuffix"] as? Int) ?? 9000 + let id = HeadlessMapTexture.create( + size: CGSize(width: width, height: height), + channelSuffix: suffix, + options: options, + registrar: registrar + ) + result(id) + case "pump": + if let id = args["textureId"] as? Int64 ?? (args["textureId"] as? Int).map(Int64.init) { + HeadlessMapTexture.pump(textureId: id) + } + result(nil) + case "ornaments": + let id = Int64((args["textureId"] as? Int) ?? -1) + result(HeadlessMapTexture.ornaments(textureId: id)) + case "resize": + let id = Int64((args["textureId"] as? Int) ?? -1) + HeadlessMapTexture.resize( + textureId: id, + size: CGSize(width: args["width"] as? Double ?? 0, + height: args["height"] as? Double ?? 0) + ) + result(nil) + case "panBegin", "panUpdate", "panEnd", "zoomBy", "rotateBy", "pitchBy": + let id = Int64((args["textureId"] as? Int) ?? -1) + let x = args["x"] as? Double ?? 0 + let y = args["y"] as? Double ?? 0 + let point = CGPoint(x: x, y: y) + switch call.method { + case "panBegin": HeadlessMapTexture.panBegin(textureId: id, at: point) + case "panUpdate": HeadlessMapTexture.panUpdate(textureId: id, to: point) + case "panEnd": HeadlessMapTexture.panEnd(textureId: id) + case "rotateBy": + HeadlessMapTexture.rotateBy(textureId: id, + radians: args["radians"] as? Double ?? 0, + at: point) + case "pitchBy": + HeadlessMapTexture.pitchBy(textureId: id, + delta: args["delta"] as? Double ?? 0) + default: + HeadlessMapTexture.zoomBy(textureId: id, + delta: args["delta"] as? Double ?? 0, + at: point) + } + result(nil) + case "dispose": + if let id = args["textureId"] as? Int64 ?? (args["textureId"] as? Int).map(Int64.init) { + HeadlessMapTexture.dispose(textureId: id) + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + private static func setupStaticChannels(with registrar: FlutterPluginRegistrar) { let binaryMessenger = registrar.messenger() diff --git a/lib/mapbox_maps_flutter.dart b/lib/mapbox_maps_flutter.dart index 24a93ea4..0f69ddb0 100644 --- a/lib/mapbox_maps_flutter.dart +++ b/lib/mapbox_maps_flutter.dart @@ -2,6 +2,7 @@ library mapbox_maps_flutter; import 'dart:async'; import 'dart:convert'; +import 'dart:ui' as ui; import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; @@ -92,4 +93,5 @@ part 'src/viewport/transitions/easing_viewport_transition.dart'; part 'src/package_info.dart'; part 'src/http/http_service.dart'; part 'src/cancelable.dart'; +part 'src/map_texture.dart'; part 'src/deprecated.dart'; diff --git a/lib/src/map_texture.dart b/lib/src/map_texture.dart new file mode 100644 index 00000000..0943b2d0 --- /dev/null +++ b/lib/src/map_texture.dart @@ -0,0 +1,182 @@ +part of '../mapbox_maps_flutter.dart'; + +/// A map that is not a platform view. +/// +/// [MapWidget] puts a `UiKitView` in the tree. UIKit composites that view, not +/// flutter, so nothing painted above it can read it: backdrop filters, shaders +/// and `toImageSync` captures all come back empty. Android avoids this by +/// rendering the map into a `TextureView`. +/// +/// [MapTexture] is that mode for ios. The map is created without a platform +/// view and its frames go to a flutter texture, so the widget tree contains a +/// [Texture] and composites like any other widget. +/// +/// The trade is that flutter owns the hit test, so gestures are forwarded +/// rather than handled by the map's own recognisers. Pan and pinch are wired; +/// rotate and pitch are not yet. +class MapTexture extends StatefulWidget { + const MapTexture({ + super.key, + this.styleUri, + this.onMapCreated, + this.gesturesEnabled = true, + }); + + /// Style to load, defaults to the sdk's standard style. + final String? styleUri; + + /// Called once with a [MapboxMap] bound to this map. Same api surface as the + /// one [MapWidget] hands back. + final void Function(MapboxMap map)? onMapCreated; + + /// Forward pan and pinch to the map. Turn off to drive the camera yourself. + final bool gesturesEnabled; + + @override + State createState() => _MapTextureState(); +} + +class _MapTextureState extends State { + static const _channel = + MethodChannel('plugins.flutter.io/mapbox_maps_headless'); + static int _nextSuffix = 90000; + + int? _textureId; + ui.Size? _size; + Timer? _pump; + bool _creating = false; + double _lastRotation = 0; + Offset? _lastFocal; + Uint8List? _ornaments; + + @override + void dispose() { + _pump?.cancel(); + final id = _textureId; + if (id != null) { + _channel.invokeMethod('dispose', {'textureId': id}); + } + super.dispose(); + } + + Future _create(ui.Size size) async { + _creating = true; + final suffix = _nextSuffix++; + final id = await _channel.invokeMethod('create', { + 'width': size.width, + 'height': size.height, + 'styleUri': widget.styleUri, + 'channelSuffix': suffix, + }); + if (!mounted || id == null || id < 0) return; + setState(() { + _textureId = id; + _size = size; + }); + widget.onMapCreated?.call(MapboxMap.headless(channelSuffix: suffix)); + _loadOrnaments(id); + // the map draws on demand, so a still map stops vending frames and the + // texture freezes on whatever it last published. + _pump = Timer.periodic(const Duration(milliseconds: 33), (_) { + _channel.invokeMethod('pump', {'textureId': id}); + }); + } + + /// The logo and attribution are UIKit views, so they are not in the map's + /// Metal output. Mapbox's terms require them on screen, so they are + /// rasterised on the host and drawn here over the texture. + Future _loadOrnaments(int id) async { + // the ornaments are laid out by uikit, so wait for a frame rather than + // guessing at a delay + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final bytes = await _channel + .invokeMethod('ornaments', {'textureId': id}); + if (!mounted || bytes == null) return; + setState(() => _ornaments = bytes); + } + + void _resize(ui.Size size) { + _size = size; + _channel.invokeMethod('resize', { + 'textureId': _textureId, + 'width': size.width, + 'height': size.height, + }); + } + + void _send(String method, [Map extra = const {}]) { + _channel.invokeMethod(method, {'textureId': _textureId, ...extra}); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = ui.Size(constraints.maxWidth, constraints.maxHeight); + if (_textureId == null) { + if (!_creating && size.width > 0 && size.height > 0) { + WidgetsBinding.instance.addPostFrameCallback((_) => _create(size)); + } + return const SizedBox.expand(); + } + if (_size != size) { + WidgetsBinding.instance.addPostFrameCallback((_) => _resize(size)); + } + final ornaments = _ornaments; + final texture = ornaments == null + ? Texture(textureId: _textureId!) + : Stack( + fit: StackFit.expand, + children: [ + Texture(textureId: _textureId!), + IgnorePointer(child: Image.memory(ornaments)), + ], + ); + if (!widget.gesturesEnabled) return texture; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onScaleStart: (d) { + _lastRotation = 0; + _lastFocal = d.localFocalPoint; + _send('panBegin', { + 'x': d.localFocalPoint.dx, + 'y': d.localFocalPoint.dy, + }); + }, + onScaleUpdate: (d) { + final x = d.localFocalPoint.dx; + final y = d.localFocalPoint.dy; + + // two fingers moving together, vertically, with no spread and no + // twist, is the sdk's pitch gesture. checked first because the + // same fingers would otherwise read as an ordinary pan. + final twoFingers = d.pointerCount >= 2; + final still = (d.scale - 1).abs() < 0.02 && d.rotation.abs() < 0.02; + if (twoFingers && still && _lastFocal != null) { + final dy = y - _lastFocal!.dy; + if (dy.abs() > 0.5) { + _send('pitchBy', {'delta': -dy * 0.25}); + _lastFocal = Offset(x, y); + return; + } + } + _lastFocal = Offset(x, y); + + _send('panUpdate', {'x': x, 'y': y}); + if ((d.scale - 1).abs() > 0.01) { + _send('zoomBy', {'delta': (d.scale - 1) * 0.5, 'x': x, 'y': y}); + } + if (d.rotation.abs() > 0.01) { + _send('rotateBy', + {'radians': d.rotation - _lastRotation, 'x': x, 'y': y}); + _lastRotation = d.rotation; + } + }, + onScaleEnd: (_) => _send('panEnd'), + child: texture, + ); + }, + ); + } +} diff --git a/lib/src/mapbox_map.dart b/lib/src/mapbox_map.dart index b7b53bce..625d6ebc 100644 --- a/lib/src/mapbox_map.dart +++ b/lib/src/mapbox_map.dart @@ -160,6 +160,22 @@ class MapboxMap extends ChangeNotifier { ); } + /// Binds to a map created without a platform view, by the headless texture + /// mode. Same controller on the host side, so every api here behaves as it + /// does for a [MapWidget]; the only difference is that the map's frames go + /// to a flutter texture instead of a UIKit view. + static MapboxMap headless({ + required int channelSuffix, + BinaryMessenger? binaryMessenger, + }) => + MapboxMap._( + mapboxMapsPlatform: _MapboxMapsPlatform( + channelSuffix: channelSuffix, + binaryMessenger: + binaryMessenger ?? ServicesBinding.instance.defaultBinaryMessenger, + ), + ); + final _MapboxMapsPlatform _mapboxMapsPlatform; /// The currently loaded Style]object. diff --git a/test/map_texture_test.dart b/test/map_texture_test.dart new file mode 100644 index 00000000..fe7ac7b4 --- /dev/null +++ b/test/map_texture_test.dart @@ -0,0 +1,157 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; + +/// MapTexture talks to the host over one method channel, so the channel is +/// where its behaviour is observable without a device: what it asks for, when, +/// and whether it cleans up after itself. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('plugins.flutter.io/mapbox_maps_headless'); + late List calls; + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + switch (call.method) { + case 'create': + return 7; + case 'ornaments': + return null; + default: + return null; + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future pumpMap(WidgetTester tester, {ui.Size size = const ui.Size(320, 640)}) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: size.width, + height: size.height, + child: const MapTexture(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + } + + Future unmount(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + + MethodCall? callNamed(List calls, String name) { + for (final call in calls) { + if (call.method == name) return call; + } + return null; + } + + testWidgets('creates the map at the size of its constraints', (tester) async { + await pumpMap(tester, size: const ui.Size(300, 500)); + final create = callNamed(calls, 'create'); + expect(create, isNotNull); + expect(create!.arguments['width'], 300.0); + expect(create.arguments['height'], 500.0); + await unmount(tester); + }); + + testWidgets('renders a Texture once the host returns an id', (tester) async { + await pumpMap(tester); + expect(find.byType(Texture), findsOneWidget); + final texture = tester.widget(find.byType(Texture)); + expect(texture.textureId, 7); + await unmount(tester); + }); + + testWidgets('every map gets its own channel suffix', (tester) async { + await pumpMap(tester); + final first = callNamed(calls, 'create')!.arguments['channelSuffix'] as int; + calls.clear(); + await tester.pumpWidget(const SizedBox.shrink()); + await pumpMap(tester); + final second = callNamed(calls, 'create')!.arguments['channelSuffix'] as int; + expect(second, isNot(first)); + await unmount(tester); + }); + + testWidgets('passes the style through', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: MapTexture(styleUri: 'mapbox://styles/test')), + ); + await tester.pump(); + await tester.pump(); + expect(callNamed(calls, 'create')!.arguments['styleUri'], + 'mapbox://styles/test'); + await unmount(tester); + }); + + testWidgets('resizes the host map when the constraints change', + (tester) async { + await pumpMap(tester, size: const ui.Size(300, 500)); + calls.clear(); + await pumpMap(tester, size: const ui.Size(200, 400)); + await tester.pump(); + final resize = callNamed(calls, 'resize'); + expect(resize, isNotNull); + expect(resize!.arguments['width'], 200.0); + expect(resize.arguments['height'], 400.0); + await unmount(tester); + }); + + testWidgets('disposes the host map when it leaves the tree', (tester) async { + await pumpMap(tester); + calls.clear(); + await tester.pumpWidget(const SizedBox.shrink()); + final dispose = callNamed(calls, 'dispose'); + expect(dispose, isNotNull); + expect(dispose!.arguments['textureId'], 7); + }); + + testWidgets('a drag forwards panBegin, panUpdate and panEnd', + (tester) async { + await pumpMap(tester); + calls.clear(); + await tester.drag(find.byType(Texture), const Offset(-40, -20)); + await tester.pumpAndSettle(); + expect(callNamed(calls, 'panBegin'), isNotNull); + expect(callNamed(calls, 'panUpdate'), isNotNull); + expect(callNamed(calls, 'panEnd'), isNotNull); + await unmount(tester); + }); + + testWidgets('gesturesEnabled false forwards nothing', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SizedBox( + width: 320, + height: 640, + child: MapTexture(gesturesEnabled: false), + ), + ), + ); + await tester.pump(); + await tester.pump(); + calls.clear(); + await tester.drag(find.byType(Texture), const Offset(-40, -20)); + await tester.pumpAndSettle(); + expect(callNamed(calls, 'panBegin'), isNull); + await unmount(tester); + }); +}