├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Minimap │ ├── Extensions.swift │ ├── Minimap.swift │ ├── MinimapHostView.swift │ ├── MinimapHostViewController.swift │ ├── MinimapHostWindow.swift │ ├── ProxyDelegate.swift │ └── Utils.swift ├── Tests ├── LinuxMain.swift └── MinimapTests │ ├── MinimapTests.swift │ └── XCTestManifests.swift └── preview.gif /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /.swiftpm 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Vladislav Prusakov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Minimap", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 11 | .library( 12 | name: "Minimap", 13 | targets: ["Minimap"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 22 | .target( 23 | name: "Minimap", 24 | dependencies: []), 25 | .testTarget( 26 | name: "MinimapTests", 27 | dependencies: ["Minimap"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimap 2 | 3 | Minimap is light way to represent your canvas to mini map. 4 | 5 | [![Swift 5.1](https://img.shields.io/badge/Swift-5.1-orange.svg?style=flat)](https://swift.org/) 6 | 7 | For base I used PKToolPicker API and Minimap works and looks like PKToolPicker. 8 | 9 | ## Features 10 | 11 | - [x] Floating minimap 12 | - [x] Customization 13 | - [x] Dark theme support 14 | - [x] PKToolPicker API 15 | - [x] Orienation support 16 | 17 | ![Example](https://github.com/SpectralDragon/Minimap/raw/master/preview.gif) 18 | 19 | ## Requirements 20 | Minimap is written in Swift 5.1 and is available on iOS 13. 21 | 22 | ## Usage 23 | 24 | For get instance Minimap using `Minimap.shared(for: UIWindow)` 25 | 26 | Example: 27 | 28 | ```swift 29 | if let minimap = Minimap.shared(for: self.view.window) { 30 | PKToolPicker.shared(for: window)?.addObserver(minimap) // For handling PKToolPicker frame 31 | minimap.observeCanvasView(canvasView) // For handling content changing 32 | minimap.setVisible(!minimap.isVisible, forFirstResponder: canvasView) // Set visible for minimap 33 | canvasView.becomeFirstResponder() 34 | 35 | minimap.tintColor = .green // Set visible zone color 36 | } 37 | ``` 38 | Minimap will automaticly hidden if responder will resign. 39 | 40 | ## How it's works? 41 | 42 | Minimap subscribe to canvas properties like `contentSize`, `contentOffset` and etc. and present new `MinimapHostWindow` for presenting minimap without adding like subview to your views. 43 | -------------------------------------------------------------------------------- /Sources/Minimap/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | import UIKit 9 | 10 | @available(iOS 10.0, *) 11 | extension UISpringTimingParameters { 12 | 13 | /// A design-friendly way to create a spring timing curve. 14 | /// 15 | /// - Parameters: 16 | /// - damping: The 'bounciness' of the animation. Value must be between 0 and 1. 17 | /// - response: The 'speed' of the animation. 18 | /// - initialVelocity: The vector describing the starting motion of the property. Optional, default is `.zero`. 19 | convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) { 20 | let stiffness = pow(2 * .pi / response, 2) 21 | let damp = 4 * .pi * damping / response 22 | self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity) 23 | } 24 | 25 | } 26 | 27 | extension CGPoint { 28 | 29 | /// Calculates the distance between two points in 2D space. 30 | /// + returns: The distance from this point to the given point. 31 | func distance(to point: CGPoint) -> CGFloat { 32 | return sqrt(pow(point.x - self.x, 2) + pow(point.y - self.y, 2)) 33 | } 34 | 35 | } 36 | 37 | extension UIView { 38 | 39 | func applyShadow(color: UIColor? = nil, blurRadius: CGFloat = 20, opacity: Float = 0.3, offset: CGSize = CGSize(width: 0, height: 5), shouldRasterize: Bool = true) { 40 | self.layer.shadowColor = color?.cgColor ?? self.backgroundColor?.cgColor 41 | self.layer.shadowOpacity = opacity 42 | self.layer.shadowOffset = offset 43 | self.layer.shadowRadius = blurRadius 44 | 45 | if shouldRasterize { 46 | self.layer.shouldRasterize = true 47 | self.layer.rasterizationScale = UIScreen.main.scale 48 | } 49 | } 50 | 51 | @available(iOS 9.0, *) 52 | func addSuperview(_ superview: UIView) { 53 | superview.addSubview(self) 54 | self.translatesAutoresizingMaskIntoConstraints = false 55 | self.topAnchor.constraint(equalTo: superview.topAnchor).isActive = true 56 | self.bottomAnchor.constraint(equalTo: superview.bottomAnchor).isActive = true 57 | self.leftAnchor.constraint(equalTo: superview.leftAnchor).isActive = true 58 | self.rightAnchor.constraint(equalTo: superview.rightAnchor).isActive = true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Minimap/Minimap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Minimap.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | 9 | import UIKit 10 | import PencilKit 11 | 12 | @available(iOS 13.0, *) 13 | public protocol MinimapObserver: AnyObject { 14 | func minimapViewVisibleDidChange(_ minimapView: Minimap) 15 | } 16 | 17 | @available(iOS 13.0, *) 18 | open class Minimap: NSObject { 19 | 20 | var window: MinimapHostWindow? 21 | weak var controller: MinimapHostViewController? 22 | private lazy var observers: ObserverSet = [] 23 | 24 | private var proxyDelegate: PKCanvasViewProxyDelegate? 25 | private(set) weak var observableCanvasView: PKCanvasView? 26 | private weak var firstResponder: UIResponder? 27 | 28 | 29 | private var scrollViewContentOffsetObservable: NSKeyValueObservation? 30 | private var scrollViewContentSizeObservable: NSKeyValueObservation? 31 | private var scrollViewContentZoomScaleObservable: NSKeyValueObservation? 32 | private var scrollViewSafeAreaObservable: NSKeyValueObservation? 33 | private var responderObserver: NSKeyValueObservation? 34 | private var previousSafeAreaInsets: UIEdgeInsets = .zero 35 | 36 | open var tintColor: UIColor = UIColor.systemBlue { 37 | didSet { 38 | self.window?.tintColor = self.tintColor 39 | } 40 | } 41 | 42 | open var isVisible: Bool { 43 | guard let controller = self.controller else { return false } 44 | return !controller.view.isHidden 45 | } 46 | 47 | open func observeCanvasView(_ canvasView: PKCanvasView) { 48 | scrollViewContentOffsetObservable = nil 49 | scrollViewContentSizeObservable = nil 50 | scrollViewContentZoomScaleObservable = nil 51 | scrollViewSafeAreaObservable = nil 52 | 53 | self.observableCanvasView = canvasView 54 | 55 | self.proxyDelegate = PKCanvasViewProxyDelegate(original: canvasView.delegate, drawingDidChangeHandler: { [weak self] canvasView in 56 | self?.controller?.minimapView.updateMinimapPreview() 57 | }) 58 | 59 | canvasView.delegate = self.proxyDelegate 60 | 61 | scrollViewSafeAreaObservable = canvasView.observe(\.safeAreaInsets, options: [.initial, .new]) { [weak self] scrollView, value in 62 | guard let safeAreaInsets = value.newValue else { return } 63 | self?.previousSafeAreaInsets = safeAreaInsets 64 | self?.controller?.additionalSafeAreaInsets = safeAreaInsets 65 | self?.needsUpdateLayoutIfNeeded() 66 | } 67 | 68 | scrollViewContentOffsetObservable = canvasView.observe(\.contentOffset) { [weak self] scrollView, value in 69 | self?.controller?.minimapView.setNeedsDisplay() 70 | } 71 | 72 | scrollViewContentSizeObservable = canvasView.observe(\.contentSize) { [weak self] scrollView, value in 73 | self?.controller?.minimapView.setNeedsDisplay() 74 | } 75 | 76 | scrollViewContentZoomScaleObservable = canvasView.observe(\.zoomScale) { [weak self] scollView, value in 77 | self?.controller?.minimapView.setNeedsDisplay() 78 | } 79 | 80 | self.controller?.minimapView.updateMinimapPreview() 81 | } 82 | 83 | /// Add an observer for a tool picker changes. 84 | /// 85 | /// Adding a `MinimapView` as an observer, will also set its initial state. 86 | /// Observers are held weakly. 87 | open func addObserver(_ observer: MinimapObserver) { 88 | self.observers.insert(observer) 89 | } 90 | 91 | /// Remove an observer for a tool picker changes. 92 | open func removeObserver(_ observer: MinimapObserver) { 93 | self.observers.remove(observer) 94 | } 95 | 96 | private func notifyObservers(_ block: @escaping (MinimapObserver) -> Void) { 97 | DispatchQueue.main.async { [weak self] in 98 | self?.observers.forEach { ($0.value as? MinimapObserver).flatMap(block) } 99 | } 100 | } 101 | 102 | private func setVisible(_ isVisible: Bool) { 103 | notifyObservers { [unowned self] in $0.minimapViewVisibleDidChange(self) } 104 | if !isVisible { 105 | self.deinitWindow() 106 | } else { 107 | self.controller?.setVisible(isVisible) 108 | self.needsUpdateLayoutIfNeeded() 109 | } 110 | } 111 | 112 | private func deinitWindow() { 113 | self.window?.dismissMinimapView { [weak self] in 114 | self?.window?.windowScene = nil 115 | self?.window = nil 116 | } 117 | } 118 | 119 | open func setVisible(_ visible: Bool, forFirstResponder responder: UIResponder) { 120 | if !visible { 121 | self.responderObserver = nil 122 | self.setVisible(false) 123 | } else { 124 | self.firstResponder = responder 125 | 126 | self.responderObserver = responder.observe(\.isFirstResponder, options: [.initial, .new], changeHandler: { [weak self] _, value in 127 | self?.setVisible(value.newValue == true) 128 | }) 129 | } 130 | } 131 | 132 | open class func shared(for window: UIWindow, size: CGSize = CGSize(width: 240, height: 128)) -> Minimap? { 133 | guard let windowScene = window.windowScene else { return nil } 134 | if let minimapHostWindow = windowScene.windows.first(where: { $0 is MinimapHostWindow }) as? MinimapHostWindow { 135 | return (minimapHostWindow.rootViewController as? MinimapHostViewController)?.minimapView.minimap 136 | } else { 137 | let minimap = Minimap() 138 | let hostWindow = MinimapHostWindow(windowScene: windowScene, minimap: minimap, size: size) 139 | hostWindow.makeKeyAndVisible() 140 | // fix bug when uiwindow hide status bar 141 | hostWindow.rootViewController?.setNeedsStatusBarAppearanceUpdate() 142 | 143 | return minimap 144 | } 145 | } 146 | 147 | } 148 | 149 | // MARK: - PKToolPickerObserver 150 | 151 | extension Minimap: PKToolPickerObserver { 152 | 153 | public func toolPickerVisibilityDidChange(_ toolPicker: PKToolPicker) { 154 | self.needsUpdateLayoutIfNeeded() 155 | } 156 | 157 | func needsUpdateLayoutIfNeeded() { 158 | guard let window = self.window, let toolPicker = PKToolPicker.shared(for: window) else { return } 159 | 160 | let frame = toolPicker.frameObscured(in: window) 161 | 162 | if window.traitCollection.userInterfaceIdiom == .phone { 163 | self.controller?.additionalSafeAreaInsets.bottom = toolPicker.isVisible ? frame.height + previousSafeAreaInsets.bottom : previousSafeAreaInsets.bottom 164 | } else { 165 | self.controller?.additionalSafeAreaInsets.bottom = self.previousSafeAreaInsets.bottom 166 | } 167 | 168 | UIView.animate(withDuration: 0.15) { 169 | window.layoutIfNeeded() 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Sources/Minimap/MinimapHostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapHostView.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | import UIKit 9 | import PencilKit 10 | 11 | @available(iOS 13.0, *) 12 | final class MinimapHostView: UIView { 13 | 14 | private weak var imageView: UIImageView! 15 | private(set) var minimap: Minimap 16 | 17 | func setup() { 18 | self.backgroundColor = UIColor { traitCollection in 19 | if traitCollection.userInterfaceStyle == .dark { 20 | return UIColor(white: 0.07, alpha: 1) 21 | } else { 22 | return .white 23 | } 24 | } 25 | let imageView = UIImageView() 26 | imageView.addSuperview(self) 27 | self.imageView = imageView 28 | self.clipsToBounds = true 29 | } 30 | 31 | required init?(coder: NSCoder) { 32 | fatalError() 33 | } 34 | 35 | override func tintColorDidChange() { 36 | super.tintColorDidChange() 37 | self.setNeedsDisplay() 38 | } 39 | 40 | init(minimap: Minimap) { 41 | self.minimap = minimap 42 | super.init(frame: .zero) 43 | self.setup() 44 | } 45 | 46 | func updateMinimapPreview() { 47 | guard let canvasView = self.minimap.observableCanvasView else { return } 48 | traitCollection.performAsCurrent { 49 | let rect = CGRect(origin: .zero, size: canvasView.contentSize) 50 | let image = canvasView.drawing.image(from: rect, scale: 0.9) 51 | self.imageView.image = image 52 | } 53 | } 54 | 55 | override func draw(_ rect: CGRect) { 56 | defer { super.draw(rect) } 57 | 58 | guard let canvasView = self.minimap.observableCanvasView else { 59 | return 60 | } 61 | 62 | let zoomScale = canvasView.zoomScale 63 | 64 | let height = (rect.height * zoomScale) * canvasView.frame.height / canvasView.contentSize.height 65 | let width = (rect.width * zoomScale) * canvasView.frame.width / canvasView.contentSize.width 66 | 67 | let positionY = rect.height / canvasView.contentSize.height * canvasView.contentOffset.y 68 | let positionX = rect.width / canvasView.contentSize.width * canvasView.contentOffset.x 69 | 70 | let visibleRect = CGRect(x: positionX, y: positionY, width: width, height: height) 71 | 72 | let visibleZonePath = UIBezierPath(rect: visibleRect) 73 | 74 | self.tintColor.setStroke() 75 | visibleZonePath.stroke() 76 | 77 | self.tintColor.withAlphaComponent(0.4).setFill() 78 | visibleZonePath.fill() 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Minimap/MinimapHostViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapHostViewController.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | import UIKit 9 | 10 | @available(iOS 13.0, *) 11 | final class MinimapHostViewController: UIViewController { 12 | 13 | unowned let minimapView: MinimapHostView 14 | private weak var minimapContainerView: UIView? 15 | private let size: CGSize 16 | 17 | init(minimapView: MinimapHostView, size: CGSize) { 18 | self.minimapView = minimapView 19 | self.size = size 20 | 21 | super.init(nibName: nil, bundle: nil) 22 | } 23 | 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | override func loadView() { 29 | let view = UIView() 30 | view.isHidden = true 31 | view.backgroundColor = .clear 32 | self.view = view 33 | } 34 | 35 | private lazy var positionViews: [MinimapPositionView] = [] 36 | 37 | private var positions: [CGPoint] { 38 | return positionViews.map { $0.center } 39 | } 40 | 41 | private let horizontalSpacing: CGFloat = 16 42 | private let verticalSpacing: CGFloat = 16 43 | 44 | func setVisible(_ visible: Bool) { 45 | self.view.isHidden = !visible 46 | } 47 | 48 | override func viewDidLoad() { 49 | super.viewDidLoad() 50 | 51 | let minimapContainerView = UIView() 52 | minimapContainerView.applyShadow(color: .black, shouldRasterize: false) 53 | self.minimapView.addSuperview(minimapContainerView) 54 | self.minimapView.layer.cornerRadius = 8 55 | 56 | let topLeftView = addPositionView() 57 | topLeftView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: horizontalSpacing).isActive = true 58 | topLeftView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 59 | 60 | let topRightView = addPositionView() 61 | topRightView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -horizontalSpacing).isActive = true 62 | topRightView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 63 | 64 | let bottomLeftView = addPositionView() 65 | bottomLeftView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: horizontalSpacing).isActive = true 66 | bottomLeftView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -verticalSpacing).isActive = true 67 | 68 | let bottomRightView = addPositionView() 69 | bottomRightView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -horizontalSpacing).isActive = true 70 | bottomRightView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -verticalSpacing).isActive = true 71 | 72 | view.addSubview(minimapContainerView) 73 | minimapContainerView.translatesAutoresizingMaskIntoConstraints = false 74 | minimapContainerView.widthAnchor.constraint(equalToConstant: size.width).isActive = true 75 | minimapContainerView.heightAnchor.constraint(equalToConstant: size.height).isActive = true 76 | 77 | let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(minimapPanned(_:))) 78 | minimapContainerView.addGestureRecognizer(panRecognizer) 79 | 80 | self.minimapContainerView = minimapContainerView 81 | 82 | minimapContainerView.transform = CGAffineTransform(scaleX: 0, y: 0) 83 | UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: { 84 | minimapContainerView.transform = .identity 85 | }, completion: nil) 86 | 87 | } 88 | 89 | func dismissMinimapView(completion: @escaping () -> Void) { 90 | self.minimapContainerView?.alpha = 1 91 | UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: { 92 | self.minimapContainerView?.alpha = 0 93 | }, completion: { _ in completion() }) 94 | } 95 | 96 | 97 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 98 | super.traitCollectionDidChange(previousTraitCollection) 99 | self.minimapView.updateMinimapPreview() 100 | } 101 | 102 | override func viewDidLayoutSubviews() { 103 | super.viewDidLayoutSubviews() 104 | 105 | self.minimapContainerView?.center = positions.last ?? .zero 106 | } 107 | 108 | private func addPositionView() -> MinimapPositionView { 109 | let view = MinimapPositionView() 110 | self.view.addSubview(view) 111 | positionViews.append(view) 112 | view.translatesAutoresizingMaskIntoConstraints = false 113 | view.widthAnchor.constraint(equalToConstant: size.width).isActive = true 114 | view.heightAnchor.constraint(equalToConstant: size.height).isActive = true 115 | return view 116 | } 117 | 118 | private var initialOffset: CGPoint = .zero 119 | 120 | @objc private func minimapPanned(_ recognizer: UIPanGestureRecognizer) { 121 | guard let minimapContainerView = self.minimapContainerView else { return } 122 | let touchPoint = recognizer.location(in: view) 123 | switch recognizer.state { 124 | case .began: 125 | initialOffset = CGPoint(x: touchPoint.x - minimapContainerView.center.x, y: touchPoint.y - minimapContainerView.center.y) 126 | case .changed: 127 | minimapContainerView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y) 128 | case .ended, .cancelled: 129 | let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue 130 | let velocity = recognizer.velocity(in: view) 131 | let projectedPosition = CGPoint( 132 | x: minimapContainerView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate), 133 | y: minimapContainerView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate) 134 | ) 135 | let nearestCornerPosition = nearestCorner(to: projectedPosition) 136 | let relativeInitialVelocity = CGVector( 137 | dx: relativeVelocity(forVelocity: velocity.x, from: minimapContainerView.center.x, to: nearestCornerPosition.x), 138 | dy: relativeVelocity(forVelocity: velocity.y, from: minimapContainerView.center.y, to: nearestCornerPosition.y) 139 | ) 140 | 141 | let timingParameters = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity) 142 | let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters) 143 | animator.addAnimations { 144 | self.minimapContainerView?.center = nearestCornerPosition 145 | } 146 | animator.startAnimation() 147 | default: break 148 | } 149 | } 150 | 151 | /// Distance traveled after decelerating to zero velocity at a constant rate. 152 | private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { 153 | return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate) 154 | } 155 | 156 | /// Finds the position of the nearest corner to the given point. 157 | private func nearestCorner(to point: CGPoint) -> CGPoint { 158 | var minDistance = CGFloat.greatestFiniteMagnitude 159 | var closestPosition = CGPoint.zero 160 | for position in self.positions { 161 | let distance = point.distance(to: position) 162 | if distance < minDistance { 163 | closestPosition = position 164 | minDistance = distance 165 | } 166 | } 167 | return closestPosition 168 | } 169 | 170 | /// Calculates the relative velocity needed for the initial velocity of the animation. 171 | private func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat { 172 | guard currentValue - targetValue != 0 else { return 0 } 173 | return velocity / (targetValue - currentValue) 174 | } 175 | } 176 | 177 | fileprivate class MinimapPositionView: UIView { 178 | 179 | override init(frame: CGRect) { 180 | super.init(frame: frame) 181 | self.backgroundColor = .clear 182 | } 183 | 184 | required init?(coder aDecoder: NSCoder) { 185 | fatalError() 186 | } 187 | 188 | } 189 | -------------------------------------------------------------------------------- /Sources/Minimap/MinimapHostWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapHostWindow.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | import UIKit 9 | 10 | @available(iOS 13.0, *) 11 | final class MinimapHostWindow: UIWindow { 12 | 13 | init(windowScene: UIWindowScene, minimap: Minimap, size: CGSize) { 14 | super.init(windowScene: windowScene) 15 | 16 | let minimapHostView = MinimapHostView(minimap: minimap) 17 | let controller = MinimapHostViewController(minimapView: minimapHostView, size: size) 18 | controller.loadViewIfNeeded() 19 | controller.view.addSuperview(self) 20 | minimap.controller = controller 21 | self.rootViewController = controller 22 | minimap.window = self 23 | } 24 | 25 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 26 | guard let view = super.hitTest(point, with: event) else { return nil } 27 | guard let hostViewController = self.rootViewController as? MinimapHostViewController else { return nil } 28 | 29 | if view === hostViewController.minimapView, !view.isHidden, view.alpha > 0.01 { 30 | return view 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func dismissMinimapView(completion: @escaping () -> Void) { 37 | (self.rootViewController as? MinimapHostViewController)?.dismissMinimapView(completion: completion) 38 | } 39 | 40 | required init?(coder: NSCoder) { 41 | fatalError() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Minimap/ProxyDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProxyDelegate.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | import UIKit 9 | import PencilKit 10 | 11 | @available(iOS 13.0, *) 12 | final class PKCanvasViewProxyDelegate: NSObject, PKCanvasViewDelegate { 13 | 14 | typealias DrawingDidChangeHandler = (_ canvasView: PKCanvasView) -> Void 15 | 16 | weak var original: PKCanvasViewDelegate? 17 | private let drawingDidChangeHandler: DrawingDidChangeHandler 18 | 19 | init(original: PKCanvasViewDelegate?, drawingDidChangeHandler: @escaping DrawingDidChangeHandler) { 20 | self.original = original 21 | self.drawingDidChangeHandler = drawingDidChangeHandler 22 | } 23 | 24 | @objc func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { 25 | self.drawingDidChangeHandler(canvasView) 26 | self.original?.canvasViewDrawingDidChange?(canvasView) 27 | } 28 | 29 | override func responds(to aSelector: Selector!) -> Bool { 30 | if aSelector == #selector(canvasViewDrawingDidChange(_:)) { 31 | return true 32 | } else { 33 | return self.original?.responds(to: aSelector) ?? false 34 | } 35 | } 36 | 37 | override func forwardingTarget(for aSelector: Selector!) -> Any? { 38 | return self.original 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Minimap/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Vladislav Prusakov on 21.07.2019. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ObserverSet: Sequence { 11 | 12 | private var delegates: Set> = [] 13 | 14 | func makeIterator() -> SetIterator> { 15 | return self.delegates.makeIterator() 16 | } 17 | 18 | @discardableResult 19 | mutating func insert(_ newMember: T) -> (inserted: Bool, memberAfterInsert: Weak) { 20 | self.removeDestroyedObservers() 21 | return self.delegates.insert(Weak(value: newMember)) 22 | } 23 | 24 | 25 | private mutating func removeDestroyedObservers() { 26 | let destroyedObservers = self.delegates.filter { $0.value == nil } 27 | destroyedObservers.forEach { observerContainer in 28 | self.delegates.remove(observerContainer) 29 | } 30 | } 31 | 32 | mutating func remove(_ member: T) { 33 | guard let container = self.delegates.first(where: { $0.value === member }) else { return } 34 | self.delegates.remove(container) 35 | } 36 | } 37 | 38 | extension ObserverSet: ExpressibleByArrayLiteral { 39 | public init(arrayLiteral elements: T...) { 40 | self.delegates = Set(elements.map(Weak.init)) 41 | } 42 | } 43 | 44 | struct Weak: Hashable { 45 | 46 | weak var value: T? 47 | private let pointeeHash: Int 48 | 49 | init(value: T) { 50 | self.value = value 51 | self.pointeeHash = withUnsafePointer(to: &self.value) { UnsafeRawPointer($0).hashValue } 52 | } 53 | 54 | // MARK: Hashable 55 | 56 | func hash(into hasher: inout Hasher) { 57 | hasher.combine(self.pointeeHash) 58 | } 59 | 60 | // MARK: Equtable 61 | 62 | static func ==(lhs: Weak, rhs: Weak) -> Bool { 63 | return lhs.value === rhs.value && lhs.pointeeHash == rhs.pointeeHash 64 | } 65 | } 66 | 67 | 68 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import MinimapTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += MinimapTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/MinimapTests/MinimapTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Minimap 3 | 4 | final class MinimapTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | 11 | static var allTests = [ 12 | ("testExample", testExample), 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Tests/MinimapTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(MinimapTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpectralDragon/Minimap/d9f28d30356b9b0bc8bee981678c7712cf5d4017/preview.gif --------------------------------------------------------------------------------