Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 98 additions & 23 deletions package/ios/GoogleMarkerVisualApplier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,63 +6,138 @@ final class GoogleMarkerVisualApplier {
private static let defaultIconToken: NSString = "__default__"
private static let defaultPinImage = GMSMarker.markerImage(with: nil)

private let appliedTokens = NSMapTable<GMSMarker, NSString>.weakToStrongObjects()
private let pendingTokens = NSMapTable<GMSMarker, NSString>.weakToStrongObjects()
private struct PendingIconLoad {
let applicationToken: NSObject
let imageToken: NSString
let descriptor: MarkerDescriptor
}

private final class MarkerIconState: NSObject {
var appliedImageToken: NSString?
var pending: PendingIconLoad?
}

private let states = NSMapTable<GMSMarker, MarkerIconState>.weakToStrongObjects()
private let cachedImage: (MarkerImage) -> UIImage?
private let loadImage: (MarkerImage, @escaping (UIImage?) -> Void) -> Void

init(
cachedImage: @escaping (MarkerImage) -> UIImage? = MarkerImageLoader.cachedImage(for:),
loadImage: @escaping (MarkerImage, @escaping (UIImage?) -> Void) -> Void = {
MarkerImageLoader.load($0, completion: $1)
}
) {
self.cachedImage = cachedImage
self.loadImage = loadImage
}

func apply(_ descriptor: MarkerDescriptor, to marker: GMSMarker) {
marker.rotation = descriptor.rotation ?? 0
marker.isFlat = descriptor.flat == true
marker.opacity = Float(descriptor.opacity ?? 1)
applyAnchor(descriptor, to: marker)
applyIcon(descriptor, to: marker)
}

private func applyAnchor(_ descriptor: MarkerDescriptor, to marker: GMSMarker) {
marker.groundAnchor = descriptor.effectiveGoogleMapsAnchor(
imageSize: anchorImageSize(descriptor, icon: marker.icon)
)
}

private func applyIcon(_ descriptor: MarkerDescriptor, to marker: GMSMarker) {
let state = state(for: marker)

guard let image = descriptor.image else {
pendingTokens.removeObject(forKey: marker)
if appliedTokens.object(forKey: marker) != Self.defaultIconToken {
cancelPending(state)
if state.appliedImageToken != Self.defaultIconToken {
marker.icon = nil
appliedTokens.setObject(Self.defaultIconToken, forKey: marker)
state.appliedImageToken = Self.defaultIconToken
}
marker.groundAnchor = descriptor.effectiveGoogleMapsAnchor(
imageSize: Self.defaultPinImage.size
)
return
}

let token = MarkerImageLoader.cacheKey(for: image)
if appliedTokens.object(forKey: marker) == token, let icon = marker.icon {
marker.groundAnchor = descriptor.effectiveGoogleMapsAnchor(imageSize: icon.size)
let imageToken = MarkerImageLoader.cacheKey(for: image)
if state.appliedImageToken == imageToken, marker.icon != nil {
if state.pending != nil {
cancelPending(state)
}
return
}

if let cached = MarkerImageLoader.cachedImage(for: image) {
pendingTokens.removeObject(forKey: marker)
setIcon(cached, token: token, on: marker, descriptor: descriptor)
if let cached = cachedImage(image) {
cancelPending(state)
setIcon(cached, token: imageToken, on: marker, descriptor: descriptor, state: state)
return
}

marker.groundAnchor = descriptor.effectiveGoogleMapsAnchor(imageSize: .zero)
pendingTokens.setObject(token, forKey: marker)
MarkerImageLoader.load(image) { [weak self, weak marker] uiImage in
guard let self, let marker, self.pendingTokens.object(forKey: marker) == token else {
cancelPending(state)
let applicationToken = NSObject()
state.pending = PendingIconLoad(
applicationToken: applicationToken,
imageToken: imageToken,
descriptor: descriptor
)
loadImage(image) { [weak self, weak marker] uiImage in
guard let self, let marker, let state = self.states.object(forKey: marker) else {
return
}
self.pendingTokens.removeObject(forKey: marker)
guard let pending = state.pending,
pending.applicationToken === applicationToken
else {
return
}
state.pending = nil
guard let uiImage else {
return
}
self.setIcon(uiImage, token: token, on: marker, descriptor: descriptor)
self.setIcon(
uiImage,
token: pending.imageToken,
on: marker,
descriptor: pending.descriptor,
state: state
)
}
}

private func setIcon(
_ uiImage: UIImage,
token: NSString,
on marker: GMSMarker,
descriptor: MarkerDescriptor
descriptor: MarkerDescriptor,
state: MarkerIconState
) {
marker.icon = uiImage
marker.groundAnchor = descriptor.effectiveGoogleMapsAnchor(imageSize: uiImage.size)
appliedTokens.setObject(token, forKey: marker)
applyAnchor(descriptor, to: marker)
state.appliedImageToken = token
}

private func state(for marker: GMSMarker) -> MarkerIconState {
if let existing = states.object(forKey: marker) {
return existing
}
let created = MarkerIconState()
states.setObject(created, forKey: marker)
return created
}

private func cancelPending(_ state: MarkerIconState) {
state.pending = nil
}

private func anchorImageSize(_ descriptor: MarkerDescriptor, icon: UIImage?) -> CGSize {
if let image = descriptor.image, let width = image.width, let height = image.height,
width > 0, height > 0
{
return CGSize(width: width, height: height)
}
if descriptor.image == nil {
return Self.defaultPinImage.size
}
if let icon, icon.size.width > 0, icon.size.height > 0 {
return icon.size
}
return Self.defaultPinImage.size
}
}
95 changes: 95 additions & 0 deletions package/iosTests/GoogleMarkerVisualApplierTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import GoogleMaps
import UIKit
import XCTest

@testable import NitroMaps

final class GoogleMarkerVisualApplierTests: XCTestCase {
func testSameUncachedImageKeepsTheLaterAnchor() {
var completions: [(UIImage?) -> Void] = []
let applier = GoogleMarkerVisualApplier(
cachedImage: { _ in nil },
loadImage: { _, completion in completions.append(completion) }
)
let marker = GMSMarker()
let image = MarkerImage(
uri: "https://example.test/pin.png",
width: 40,
height: 40,
scale: 1
)

applier.apply(markerDescriptor(image: image, anchor: MarkerAnchor(x: 0, y: 0)), to: marker)
applier.apply(markerDescriptor(image: image, anchor: MarkerAnchor(x: 1, y: 1)), to: marker)

XCTAssertEqual(completions.count, 2)

let staleIcon = makeIcon()
let latestIcon = makeIcon()
completions[0](staleIcon)

XCTAssertNil(marker.icon)

completions[1](latestIcon)

XCTAssertTrue(marker.icon === latestIcon)
XCTAssertEqual(marker.groundAnchor.x, 1, accuracy: 0.0001)
XCTAssertEqual(marker.groundAnchor.y, 1, accuracy: 0.0001)
}

func testAppliedImageIsNotReplacedByAStalePendingLoad() {
let iconA = makeIcon()
let iconB = makeIcon()
var completions: [(UIImage?) -> Void] = []
let imageA = MarkerImage(
uri: "https://example.test/a.png",
width: 40,
height: 40,
scale: 1
)
let imageB = MarkerImage(
uri: "https://example.test/b.png",
width: 40,
height: 40,
scale: 1
)
let applier = GoogleMarkerVisualApplier(
cachedImage: { image in image.uri.hasSuffix("a.png") ? iconA : nil },
loadImage: { _, completion in completions.append(completion) }
)
let marker = GMSMarker()

applier.apply(markerDescriptor(image: imageA, anchor: MarkerAnchor(x: 0, y: 0)), to: marker)
applier.apply(markerDescriptor(image: imageB, anchor: MarkerAnchor(x: 1, y: 1)), to: marker)
applier.apply(markerDescriptor(image: imageA, anchor: MarkerAnchor(x: 0, y: 0)), to: marker)

XCTAssertEqual(completions.count, 1)
completions[0](iconB)

XCTAssertTrue(marker.icon === iconA)
XCTAssertEqual(marker.groundAnchor.x, 0, accuracy: 0.0001)
XCTAssertEqual(marker.groundAnchor.y, 0, accuracy: 0.0001)
}
}

private func markerDescriptor(image: MarkerImage, anchor: MarkerAnchor) -> MarkerDescriptor {
MarkerDescriptor(
id: "marker",
coordinate: Coordinate(latitude: 0, longitude: 0),
title: nil,
subtitle: nil,
draggable: nil,
clusterable: nil,
image: image,
anchor: anchor,
centerOffset: nil,
rotation: nil,
flat: nil,
opacity: nil,
enteringAnimation: nil
)
}

private func makeIcon() -> UIImage {
UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { _ in }
}
4 changes: 4 additions & 0 deletions package/react-native-better-maps.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,8 @@ Pod::Spec.new do |s|
add_nitrogen_files(s)

install_modules_dependencies(s)

s.test_spec 'Tests' do |test_spec|
test_spec.source_files = 'iosTests/**/*.swift'
end
end
Loading