Skip to content

Commit

Permalink
Camera animations lifecycle listener (#2114)
Browse files Browse the repository at this point in the history
- Allows listening to start/stop events of camera animator based on its animation owner, or by observing directly from the animator itself when created from low-level camera API.
Co-authored-by: Patrick Leonard <[email protected]>
  • Loading branch information
maios authored Apr 22, 2024
1 parent 4d86939 commit 448535e
Show file tree
Hide file tree
Showing 30 changed files with 530 additions and 220 deletions.
12 changes: 12 additions & 0 deletions Apps/Examples/Examples/All Examples/CameraAnimationExample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,17 @@ final class CameraAnimationExample: UIViewController, ExampleProtocol {
self?.finish()
}
}.store(in: &cancelables)

mapView.camera
.onCameraAnimatorStarted { animator in
print("Animator started: \(animator.owner)")
}
.store(in: &cancelables)

mapView.camera
.onCameraAnimatorStopped { (animator, isCancelled) in
print("Animator stopped: \(animator.owner), isCancelled: \( isCancelled)")
}
.store(in: &cancelables)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ final class CameraAnimatorsExample: UIViewController, ExampleProtocol {
func startCameraAnimations() {
os_log(.default, "Animating zoom from zoom to lvl 14")

// Declare an animator that changes the map's
// Declare an animator that changes the map's bearing
let bearingAnimator = mapView.camera.makeAnimator(duration: 4, curve: .easeInOut) { (transition) in
transition.bearing.toValue = -45
}
Expand All @@ -87,6 +87,9 @@ final class CameraAnimatorsExample: UIViewController, ExampleProtocol {
self.animationState = .stop
}
}
bearingAnimator.onStarted.observe {
os_log(.default, "Bearing animator has started")
}.store(in: &cancelables)

// Declare an animator that changes the map's pitch.
let pitchAnimator = mapView.camera.makeAnimator(duration: 2, curve: .easeInOut) { (transition) in
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ Mapbox welcomes participation and contributions from everyone.


* Allow to assign slot to 2D and 3D location indicators.
* Allow observing start/stop event of `CameraAnimator`
You can observe start/stop event of `CameraAnimator` by using new `CameraAnimationsManager` APIs as shown below
```
// Observe start event of any CameraAnimator owned by AnimationOwner.cameraAnimationsManager
mapView.camera
.onCameraAnimatorStarted(with: [.cameraAnimationsManager]) { cameraAnimator in
// Handle camera animation started here.
}
.store(in: &cancelables)
// Observe stop events of any CameraAnimator owned by AnimationOwner.cameraAnimationsManager, either when the animator has finished animating or it is interrupted
mapView.camera
.onCameraAnimatorStopped { (animator, isCancelled) in
// Handle camera animation stopped here.
}
.store(in: &cancelables)
```
You can also observe directly on an instance of `CameraAnimator` when using low-level camera APIs to create a custom animator
```
// Declare an animator that changes the map's bearing
let bearingAnimator = mapView.camera.makeAnimator(duration: 4, curve: .easeInOut) { (transition) in
transition.bearing.toValue = -45
}
bearingAnimator.onStarted.observe {
// Bearing animator has started.
}.store(in: &cancelables)
```
* Allow to add slots at runtime.
* Expose API to interact with styile imports using Declarative Styling and regular imperative API.
* Expose `StyleImport` for declarative styling as `MapStyleContent`.
Expand Down
40 changes: 26 additions & 14 deletions Sources/MapboxMaps/Camera/BasicCameraAnimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ public final class BasicCameraAnimator: CameraAnimator, CameraAnimatorProtocol {
impl.animationType
}

internal weak var delegate: CameraAnimatorDelegate?

/// Defines the transition that will occur to the `CameraOptions` of the renderer due to this animator
public var transition: CameraTransition? {
impl.transition
Expand Down Expand Up @@ -49,9 +47,33 @@ public final class BasicCameraAnimator: CameraAnimator, CameraAnimatorProtocol {
set { impl.fractionComplete = newValue }
}

internal init(impl: BasicCameraAnimatorProtocol) {
var onCameraAnimatorStatusChanged: Signal<CameraAnimatorStatus> {
impl.onCameraAnimatorStatusChanged
}

/// Emits a signal when this animator has started.
public var onStarted: Signal<Void> {
onCameraAnimatorStatusChanged
.filter { $0 == .started }
.map { _ in }
}

/// Emits a signal when this animator has finished.
public var onFinished: Signal<Void> {
onCameraAnimatorStatusChanged
.filter { $0 == .stopped(reason: .finished) }
.map { _ in }
}

/// Emits a signal when this animator is cancelled.
public var onCancelled: Signal<Void> {
onCameraAnimatorStatusChanged
.filter { $0 == .stopped(reason: .cancelled) }
.map { _ in }
}

init(impl: BasicCameraAnimatorProtocol) {
self.impl = impl
impl.delegate = self
}

/// Starts the animation if this animator is in `inactive` state. Also used to resume a "paused"
Expand Down Expand Up @@ -101,13 +123,3 @@ public final class BasicCameraAnimator: CameraAnimator, CameraAnimatorProtocol {
impl.update()
}
}

extension BasicCameraAnimator: BasicCameraAnimatorDelegate {
internal func basicCameraAnimatorDidStartRunning(_ animator: BasicCameraAnimatorProtocol) {
delegate?.cameraAnimatorDidStartRunning(self)
}

internal func basicCameraAnimatorDidStopRunning(_ animator: BasicCameraAnimatorProtocol) {
delegate?.cameraAnimatorDidStopRunning(self)
}
}
22 changes: 10 additions & 12 deletions Sources/MapboxMaps/Camera/BasicCameraAnimatorImpl.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import UIKit

internal protocol BasicCameraAnimatorDelegate: AnyObject {
func basicCameraAnimatorDidStartRunning(_ animator: BasicCameraAnimatorProtocol)
func basicCameraAnimatorDidStopRunning(_ animator: BasicCameraAnimatorProtocol)
}

internal protocol BasicCameraAnimatorProtocol: AnyObject {
var delegate: BasicCameraAnimatorDelegate? { get set }
var owner: AnimationOwner { get }
var animationType: AnimationType { get }
var transition: CameraTransition? { get }
Expand All @@ -15,6 +9,7 @@ internal protocol BasicCameraAnimatorProtocol: AnyObject {
var isReversed: Bool { get set }
var pausesOnCompletion: Bool { get set }
var fractionComplete: Double { get set }
var onCameraAnimatorStatusChanged: Signal<CameraAnimatorStatus> { get }
func startAnimation()
func startAnimation(afterDelay delay: TimeInterval)
func pauseAnimation()
Expand Down Expand Up @@ -51,8 +46,6 @@ internal final class BasicCameraAnimatorImpl: BasicCameraAnimatorProtocol {

private let mainQueue: MainQueueProtocol

internal weak var delegate: BasicCameraAnimatorDelegate?

/// Represents the animation that this animator is attempting to execute
private let animation: Animation

Expand All @@ -68,21 +61,26 @@ internal final class BasicCameraAnimatorImpl: BasicCameraAnimatorProtocol {
}
}

private let cameraAnimatorStatusSignal = SignalSubject<CameraAnimatorStatus>()
var onCameraAnimatorStatusChanged: Signal<CameraAnimatorStatus> { cameraAnimatorStatusSignal.signal }

/// The state from of the animator.
internal var state: UIViewAnimatingState { propertyAnimator.state }

private var internalState = InternalState.initial {
didSet {
switch (oldValue, internalState) {
case (.initial, .running), (.paused, .running):
delegate?.basicCameraAnimatorDidStartRunning(self)
case (.running, .paused), (.running, .final):
delegate?.basicCameraAnimatorDidStopRunning(self)
cameraAnimatorStatusSignal.send(.started)
case (.running, .paused):
cameraAnimatorStatusSignal.send(.paused)
case (.running, .final(let position)), (.paused, .final(let position)):
let isCancelled = position != .end
cameraAnimatorStatusSignal.send(.stopped(reason: isCancelled ? .cancelled : .finished))
default:
// this matches cases where…
// * oldValue and internalState are the same
// * initial transitions to paused
// * paused transitions to final
// * initial transitions to final
// * the transition is invalid…
// * running/paused/final --> initial
Expand Down
20 changes: 20 additions & 0 deletions Sources/MapboxMaps/Camera/CameraAnimationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,24 @@ public final class CameraAnimationsManager {
animationOwner: animationOwner,
animations: animations)
}

/// Adds an observer when a ``CameraAnimator`` has started with the given `owner`.
///
/// - Parameters
/// - owners: The list of ``AnimationOwner``s that own the starting camera animator. The default is an empty list, which observes all animation owners.
/// - handler: The handler to be invoked with a ``CameraAnimator`` as the argument when this animator is starting.
public func onCameraAnimatorStarted(with owners: [AnimationOwner] = [], handler: @escaping OnCameraAnimatorStarted) -> AnyCancelable {
let observer = CameraAnimatorStatusObserver(owners: owners, onStarted: handler)
return impl.add(cameraAnimatorStatusObserver: observer)
}

/// Adds an observer when a ``CameraAnimator`` has stopped with the given `owner`.
///
/// - Parameters
/// - owners: The list of ``AnimationOwner``s that own the stopping camera animator. The default is an empty list, which observes all animation owners.
/// - handler: The handler to be invoked with a ``CameraAnimator`` as the argument when this animator is stopping.
public func onCameraAnimatorStopped(owners: [AnimationOwner] = [], handler: @escaping OnCameraAnimatorStopped) -> AnyCancelable {
let observer = CameraAnimatorStatusObserver(owners: owners, onStopped: handler)
return impl.add(cameraAnimatorStatusObserver: observer)
}
}
8 changes: 8 additions & 0 deletions Sources/MapboxMaps/Camera/CameraAnimationsManagerImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ internal protocol CameraAnimationsManagerProtocol: AnyObject {
duration: TimeInterval,
curve: TimingCurve,
owner: AnimationOwner) -> SimpleCameraAnimatorProtocol
func add(cameraAnimatorStatusObserver observer: CameraAnimatorStatusObserver) -> AnyCancelable
}

internal final class CameraAnimationsManagerImpl: CameraAnimationsManagerProtocol {
Expand Down Expand Up @@ -237,4 +238,11 @@ internal final class CameraAnimationsManagerImpl: CameraAnimationsManagerProtoco
runner.add(animator)
return animator
}

func add(cameraAnimatorStatusObserver observer: CameraAnimatorStatusObserver) -> AnyCancelable {
runner.add(cameraAnimatorStatusObserver: observer)
return AnyCancelable { [weak runner] in
runner?.remove(cameraAnimatorStatusObserver: observer)
}
}
}
8 changes: 1 addition & 7 deletions Sources/MapboxMaps/Camera/CameraAnimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ internal protocol CameraAnimatorProtocol: CameraAnimator {
/// Type of the embeded animation
var animationType: AnimationType { get }

/// Implementations must use a weak reference.
var delegate: CameraAnimatorDelegate? { get set }

/// Adds a completion block to the animator. If the animator is already complete,
/// implementations should invoke the completion block asynchronously with the
/// same `UIViewAnimatingPosition` value as when it completed.
Expand All @@ -30,9 +27,6 @@ internal protocol CameraAnimatorProtocol: CameraAnimator {

/// Called at each display link to allow animators to update the camera.
func update()
}

internal protocol CameraAnimatorDelegate: AnyObject {
func cameraAnimatorDidStartRunning(_ cameraAnimator: CameraAnimatorProtocol)
func cameraAnimatorDidStopRunning(_ cameraAnimator: CameraAnimatorProtocol)
var onCameraAnimatorStatusChanged: Signal<CameraAnimatorStatus> { get }
}
32 changes: 32 additions & 0 deletions Sources/MapboxMaps/Camera/CameraAnimatorStatusObservable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

enum CameraAnimatorStatus: Equatable {
case started
case stopped(reason: StopReason)
case paused

enum StopReason: Equatable {
case finished, cancelled
}
}

final class CameraAnimatorStatusObserver {
let owners: [AnimationOwner]
let onStarted: OnCameraAnimatorStarted?
let onStopped: OnCameraAnimatorStopped?

init(
owners: [AnimationOwner],
onStarted: OnCameraAnimatorStarted? = nil,
onStopped: OnCameraAnimatorStopped? = nil
) {
self.owners = owners
self.onStarted = onStarted
self.onStopped = onStopped
}
}

/// A closure to handle event when a camera animator has started.
public typealias OnCameraAnimatorStarted = (CameraAnimator) -> Void
/// A closure to handle event when a camera animator has stopped.
public typealias OnCameraAnimatorStopped = (CameraAnimator, _ isCancelled: Bool) -> Void
54 changes: 48 additions & 6 deletions Sources/MapboxMaps/Camera/CameraAnimatorsRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ internal protocol CameraAnimatorsRunnerProtocol: AnyObject {
func cancelAnimations(withOwners owners: [AnimationOwner])
func cancelAnimations(withOwners owners: [AnimationOwner], andTypes: [AnimationType])
func add(_ animator: CameraAnimatorProtocol)
func add(cameraAnimatorStatusObserver observer: CameraAnimatorStatusObserver)
func remove(cameraAnimatorStatusObserver observer: CameraAnimatorStatusObserver)
}

internal final class CameraAnimatorsRunner: CameraAnimatorsRunnerProtocol {
Expand All @@ -24,6 +26,8 @@ internal final class CameraAnimatorsRunner: CameraAnimatorsRunnerProtocol {
}
}

private var cameraAnimatorStatusObservers = WeakSet<CameraAnimatorStatusObserver>()

/// See ``CameraAnimationsManager/cameraAnimators``.
internal var cameraAnimators: [CameraAnimator] {
return allCameraAnimators.allObjects
Expand All @@ -34,8 +38,8 @@ internal final class CameraAnimatorsRunner: CameraAnimatorsRunnerProtocol {

/// Strong references only to running camera animators
private var runningCameraAnimators = [CameraAnimatorProtocol]()

private let mapboxMap: MapboxMapProtocol
private var cancelables: Set<AnyCancelable> = []

internal init(mapboxMap: MapboxMapProtocol) {
self.mapboxMap = mapboxMap
Expand Down Expand Up @@ -71,16 +75,35 @@ internal final class CameraAnimatorsRunner: CameraAnimatorsRunnerProtocol {
}
}

internal func add(_ animator: CameraAnimatorProtocol) {
animator.delegate = self
func add(_ animator: CameraAnimatorProtocol) {
allCameraAnimators.add(animator)

animator.onCameraAnimatorStatusChanged.observe { [weak self] status in
switch status {
case .started:
self?.cameraAnimatorDidStartRunning(animator)
case .stopped(let reason):
self?.cameraAnimatorDidStopRunning(animator, reason: reason)
case .paused:
self?.cameraAnimatorDidPause(animator)
}
}
.store(in: &cancelables)
if !isEnabled {
animator.stopAnimation()
}
}

func add(cameraAnimatorStatusObserver observer: CameraAnimatorStatusObserver) {
cameraAnimatorStatusObservers.add(observer)
}

func remove(cameraAnimatorStatusObserver observer: CameraAnimatorStatusObserver) {
cameraAnimatorStatusObservers.remove(observer)
}
}

extension CameraAnimatorsRunner: CameraAnimatorDelegate {
extension CameraAnimatorsRunner {
/// When an animator starts running, `CameraAnimationsRunner` takes a strong reference to it
/// so that it stays alive while it is running. It also calls `beginAnimation` on `MapboxMap`.
///
Expand All @@ -92,11 +115,14 @@ extension CameraAnimatorsRunner: CameraAnimatorDelegate {
///
/// Moving this responsibility to `CameraAnimationsRunner` means that if the `MapView` is
/// deallocated, these strong references will be released as well.
internal func cameraAnimatorDidStartRunning(_ cameraAnimator: CameraAnimatorProtocol) {
private func cameraAnimatorDidStartRunning(_ cameraAnimator: CameraAnimatorProtocol) {
if !runningCameraAnimators.contains(where: { $0 === cameraAnimator }) {
runningCameraAnimators.append(cameraAnimator)
mapboxMap.beginAnimation()
}
for observer in cameraAnimatorStatusObservers.allObjects where observer.owners.isEmpty || observer.owners.contains(cameraAnimator.owner) {
observer.onStarted?(cameraAnimator)
}
}

/// When an animator stops running, `CameraAnimationsRunner` releases its strong reference to
Expand All @@ -105,7 +131,23 @@ extension CameraAnimatorsRunner: CameraAnimatorDelegate {
///
/// See `cameraAnimatorDidStartRunning(_:)` for further discussion of the rationale for this
/// architecture.
internal func cameraAnimatorDidStopRunning(_ cameraAnimator: CameraAnimatorProtocol) {
private func cameraAnimatorDidStopRunning(_ cameraAnimator: CameraAnimatorProtocol, reason: CameraAnimatorStatus.StopReason) {
if runningCameraAnimators.contains(where: { $0 === cameraAnimator }) {
runningCameraAnimators.removeAll { $0 === cameraAnimator }
mapboxMap.endAnimation()
}
for observer in cameraAnimatorStatusObservers.allObjects where observer.owners.isEmpty || observer.owners.contains(cameraAnimator.owner) {
observer.onStopped?(cameraAnimator, reason == .cancelled)
}
}

/// When an animator is paused, `CameraAnimationsRunner` releases its strong reference to
/// it so that it can be deinited if there are no other owning references. It also calls `endAnimation`
/// on `MapboxMap`, upon resuming, it will be added back to the runner.
///
/// See `cameraAnimatorDidStartRunning(_:)` for further discussion of the rationale for this
/// architecture.
private func cameraAnimatorDidPause(_ cameraAnimator: CameraAnimatorProtocol) {
if runningCameraAnimators.contains(where: { $0 === cameraAnimator }) {
runningCameraAnimators.removeAll { $0 === cameraAnimator }
mapboxMap.endAnimation()
Expand Down
Loading

0 comments on commit 448535e

Please sign in to comment.