-
Notifications
You must be signed in to change notification settings - Fork 313
/
InstructionPresenter.swift
350 lines (281 loc) · 15.5 KB
/
InstructionPresenter.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
import UIKit
import MapboxDirections
protocol InstructionPresenterDataSource: class {
var availableBounds: (() -> CGRect)! { get }
var font: UIFont! { get }
var textColor: UIColor! { get }
var shieldHeight: CGFloat { get }
}
typealias DataSource = InstructionPresenterDataSource
class InstructionPresenter {
private let instruction: VisualInstruction
private weak var dataSource: DataSource?
required init(_ instruction: VisualInstruction, dataSource: DataSource, imageRepository: ImageRepository = .shared, downloadCompletion: ShieldDownloadCompletion?) {
self.instruction = instruction
self.dataSource = dataSource
self.imageRepository = imageRepository
self.onShieldDownload = downloadCompletion
}
typealias ImageDownloadCompletion = (UIImage?) -> Void
typealias ShieldDownloadCompletion = (NSAttributedString) -> ()
let onShieldDownload: ShieldDownloadCompletion?
private let imageRepository: ImageRepository
func attributedText() -> NSAttributedString {
let string = NSMutableAttributedString()
fittedAttributedComponents().forEach { string.append($0) }
return string
}
func fittedAttributedComponents() -> [NSAttributedString] {
guard let source = self.dataSource else { return [] }
var attributedPairs = self.attributedPairs(for: instruction, dataSource: source, imageRepository: imageRepository, onImageDownload: completeShieldDownload)
let availableBounds = source.availableBounds()
let totalWidth: CGFloat = attributedPairs.attributedStrings.map { $0.size() }.reduce(.zero, +).width
let stringFits = totalWidth <= availableBounds.width
guard !stringFits else { return attributedPairs.attributedStrings }
let indexedComponents: [IndexedVisualInstructionComponent] = attributedPairs.components.enumerated().map { IndexedVisualInstructionComponent(component: $1, index: $0) }
let filtered = indexedComponents.filter { $0.component.abbreviation != nil }
let sorted = filtered.sorted { $0.component.abbreviationPriority < $1.component.abbreviationPriority }
for component in sorted {
let isFirst = component.index == 0
let joinChar = isFirst ? "" : " "
guard component.component.type == .text else { continue }
guard let abbreviation = component.component.abbreviation else { continue }
attributedPairs.attributedStrings[component.index] = NSAttributedString(string: joinChar + abbreviation, attributes: attributes(for: source))
let newWidth: CGFloat = attributedPairs.attributedStrings.map { $0.size() }.reduce(.zero, +).width
if newWidth <= availableBounds.width {
break
}
}
return attributedPairs.attributedStrings
}
typealias AttributedInstructionComponents = (components: [VisualInstructionComponent], attributedStrings: [NSAttributedString])
func attributedPairs(for instruction: VisualInstruction, dataSource: DataSource, imageRepository: ImageRepository, onImageDownload: @escaping ImageDownloadCompletion) -> AttributedInstructionComponents {
let components = instruction.components.compactMap { $0 as? VisualInstructionComponent }
var strings: [NSAttributedString] = []
var processedComponents: [VisualInstructionComponent] = []
for (index, component) in components.enumerated() {
let isFirst = index == 0
let joinChar = isFirst ? "" : " "
let joinString = NSAttributedString(string: joinChar, attributes: attributes(for: dataSource))
let initial = NSAttributedString()
//This is the closure that builds the string.
let build: (_: VisualInstructionComponent, _: [NSAttributedString]) -> Void = { (component, attributedStrings) in
processedComponents.append(component)
strings.append(attributedStrings.reduce(initial, +))
}
let isShield: (_: VisualInstructionComponent?) -> Bool = { (component) in
guard let key = component?.cacheKey else { return false }
return imageRepository.cachedImageForKey(key) != nil
}
let componentBefore = components.component(before: component)
let componentAfter = components.component(after: component)
switch component.type {
//Throw away exit components. We know this is safe because we know that if there is an exit component,
// there is an exit code component, and the latter contains the information we care about.
case .exit:
continue
//If we have a exit, in the first two components, lets handle that.
case .exitCode where 0...1 ~= index:
guard let exitString = self.attributedString(forExitComponent: component, maneuverDirection: instruction.maneuverDirection, dataSource: dataSource) else { fallthrough }
build(component, [exitString])
//if it's a delimiter, skip it if it's between two shields.
case .delimiter where isShield(componentBefore) && isShield(componentAfter):
continue
//If we have an icon component, lets turn it into a shield.
case .image:
if let shieldString = attributedString(forShieldComponent: component, repository: imageRepository, dataSource: dataSource, onImageDownload: onImageDownload) {
build(component, [joinString, shieldString])
} else if let genericShieldString = attributedString(forGenericShield: component, dataSource: dataSource) {
build(component, [joinString, genericShieldString])
} else {
fallthrough
}
//Otherwise, process as text component.
default:
guard let componentString = attributedString(forTextComponent: component, dataSource: dataSource) else { continue }
build(component, [joinString, componentString])
}
}
assert(processedComponents.count == strings.count, "The number of processed components must match the number of attributed strings")
return (components: processedComponents, attributedStrings: strings)
}
func attributedString(forExitComponent component: VisualInstructionComponent, maneuverDirection: ManeuverDirection, dataSource: DataSource) -> NSAttributedString? {
guard component.type == .exitCode, let exitCode = component.text else { return nil }
let side: ExitSide = maneuverDirection == .left ? .left : .right
guard let exitString = exitShield(side: side, text: exitCode, component: component, dataSource: dataSource) else { return nil }
return exitString
}
func attributedString(forGenericShield component: VisualInstructionComponent, dataSource: DataSource) -> NSAttributedString? {
guard component.type == .image, let text = component.text else { return nil }
return genericShield(text: text, component: component, dataSource: dataSource)
}
func attributedString(forShieldComponent shield: VisualInstructionComponent, repository:ImageRepository, dataSource: DataSource, onImageDownload: @escaping ImageDownloadCompletion) -> NSAttributedString? {
guard shield.imageURL != nil, let shieldKey = shield.cacheKey else { return nil }
//If we have the shield already cached, use that.
if let cachedImage = repository.cachedImageForKey(shieldKey) {
return attributedString(withFont: dataSource.font, shieldImage: cachedImage)
}
// Let's download the shield
shieldImageForComponent(shield, in: repository, height: dataSource.shieldHeight, completion: onImageDownload)
//Return nothing in the meantime, triggering downstream behavior (generic shield or text)
return nil
}
func attributedString(forTextComponent component: VisualInstructionComponent, dataSource: DataSource) -> NSAttributedString? {
guard let text = component.text else { return nil }
return NSAttributedString(string: text, attributes: attributes(for: dataSource))
}
private func shieldImageForComponent(_ component: VisualInstructionComponent, in repository: ImageRepository, height: CGFloat, completion: @escaping ImageDownloadCompletion) {
guard let imageURL = component.imageURL, let shieldKey = component.cacheKey else {
return
}
repository.imageWithURL(imageURL, cacheKey: shieldKey, completion: completion )
}
private func instructionHasDownloadedAllShields() -> Bool {
let textComponents = instruction.components.compactMap { $0 as? VisualInstructionComponent }
guard !textComponents.isEmpty else { return false }
for component in textComponents {
guard let key = component.cacheKey else {
continue
}
if imageRepository.cachedImageForKey(key) == nil {
return false
}
}
return true
}
private func attributes(for dataSource: InstructionPresenterDataSource) -> [NSAttributedStringKey: Any] {
return [.font: dataSource.font as Any, .foregroundColor: dataSource.textColor as Any]
}
private func attributedString(withFont font: UIFont, shieldImage: UIImage) -> NSAttributedString {
let attachment = ShieldAttachment()
attachment.font = font
attachment.image = shieldImage
return NSAttributedString(attachment: attachment)
}
private func genericShield(text: String, component: VisualInstructionComponent, dataSource: DataSource) -> NSAttributedString? {
guard let cacheKey = component.cacheKey else { return nil }
let additionalKey = GenericRouteShield.criticalHash(dataSource: dataSource)
let attachment = GenericShieldAttachment()
let key = [cacheKey, additionalKey].joined(separator: "-")
if let image = imageRepository.cachedImageForKey(key) {
attachment.image = image
} else {
let view = GenericRouteShield(pointSize: dataSource.font.pointSize, text: text)
guard let image = takeSnapshot(on: view) else { return nil }
imageRepository.storeImage(image, forKey: key, toDisk: false)
attachment.image = image
}
attachment.font = dataSource.font
return NSAttributedString(attachment: attachment)
}
private func exitShield(side: ExitSide = .right, text: String, component: VisualInstructionComponent, dataSource: DataSource) -> NSAttributedString? {
guard let cacheKey = component.cacheKey else { return nil }
let additionalKey = ExitView.criticalHash(side: side, dataSource: dataSource)
let attachment = ExitAttachment()
let key = [cacheKey, additionalKey].joined(separator: "-")
if let image = imageRepository.cachedImageForKey(key) {
attachment.image = image
} else {
let view = ExitView(pointSize: dataSource.font.pointSize, side: side, text: text)
guard let image = takeSnapshot(on: view) else { return nil }
imageRepository.storeImage(image, forKey: key, toDisk: false)
attachment.image = image
}
attachment.font = dataSource.font
return NSAttributedString(attachment: attachment)
}
private func completeShieldDownload(_ image: UIImage?) {
//We *must* be on main thread here, because attributedText() looks at object properties only accessible on main thread.
DispatchQueue.main.async {
self.onShieldDownload?(self.attributedText()) //FIXME: Can we work with the image directly?
}
}
private func takeSnapshot(on view: UIView) -> UIImage? {
let window: UIWindow
if let hostView = dataSource as? UIView, let hostWindow = hostView.window {
window = hostWindow
} else {
window = UIApplication.shared.delegate!.window!!
}
// Temporarily add the view to the view hierarchy for UIAppearance to work its magic.
window.addSubview(view)
let image = view.imageRepresentation
view.removeFromSuperview()
return image
}
}
protocol ImagePresenter: TextPresenter {
var image: UIImage? { get }
}
protocol TextPresenter {
var text: String? { get }
var font: UIFont { get }
}
class ImageInstruction: NSTextAttachment, ImagePresenter {
var font: UIFont = UIFont.systemFont(ofSize: UIFont.systemFontSize)
var text: String?
override func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
guard let image = image else {
return super.attachmentBounds(for: textContainer, proposedLineFragment: lineFrag, glyphPosition: position, characterIndex: charIndex)
}
let yOrigin = (font.capHeight - image.size.height).rounded() / 2
return CGRect(x: 0, y: yOrigin, width: image.size.width, height: image.size.height)
}
}
class TextInstruction: ImageInstruction {}
class ShieldAttachment: ImageInstruction {}
class GenericShieldAttachment: ShieldAttachment {}
class ExitAttachment: ImageInstruction {}
class RoadNameLabelAttachment: TextInstruction {
var scale: CGFloat?
var color: UIColor?
var compositeImage: UIImage? {
guard let image = image, let text = text, let color = color, let scale = scale else {
return nil
}
var currentImage: UIImage?
let textHeight = font.lineHeight
let pointY = (image.size.height - textHeight) / 2
currentImage = image.insert(text: text as NSString, color: color, font: font, atPoint: CGPoint(x: 0, y: pointY), scale: scale)
return currentImage
}
convenience init(image: UIImage, text: String, color: UIColor, font: UIFont, scale: CGFloat) {
self.init()
self.image = image
self.font = font
self.text = text
self.color = color
self.scale = scale
self.image = compositeImage ?? image
}
}
extension CGSize {
fileprivate static var greatestFiniteSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
fileprivate static func +(lhs: CGSize, rhs: CGSize) -> CGSize {
return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
}
fileprivate struct IndexedVisualInstructionComponent {
let component: Array<VisualInstructionComponent>.Element
let index: Array<VisualInstructionComponent>.Index
}
extension Array where Element == VisualInstructionComponent {
fileprivate func component(before component: VisualInstructionComponent) -> VisualInstructionComponent? {
guard let index = self.index(of: component) else {
return nil
}
if index > 0 {
return self[index-1]
}
return nil
}
fileprivate func component(after component: VisualInstructionComponent) -> VisualInstructionComponent? {
guard let index = self.index(of: component) else {
return nil
}
if index+1 < self.endIndex {
return self[index+1]
}
return nil
}
}