-
Notifications
You must be signed in to change notification settings - Fork 312
/
InstructionPresenter.swift
294 lines (245 loc) · 14.1 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
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
extension NSAttributedString.Key {
/**
A string containing an abbreviation that can be substituted for the substring when there is not enough room to display the original substring.
*/
static let abbreviation = NSAttributedString.Key(rawValue: "MBVisualInstructionComponentAbbreviation")
/**
A number indicating the priority for which the substring should be substituted with the abbreviation specified by the `NSAttributedString.Key.abbreviation` key.
A substring with a lower abbreviation priority value should be abbreviated before a substring with a higher abbreviation priority value.
*/
static let abbreviationPriority = NSAttributedString.Key(rawValue: "MBVisualInstructionComponentAbbreviationPriority")
}
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 {
guard let source = self.dataSource else {
return NSAttributedString()
}
let attributedTextRepresentation = self.attributedTextRepresentation(of: instruction, dataSource: source, imageRepository: imageRepository, onImageDownload: completeShieldDownload).mutableCopy() as! NSMutableAttributedString
// Collect abbreviation priorities embedded in the attributed text representation.
let wholeRange = NSRange(location: 0, length: attributedTextRepresentation.length)
var priorities = IndexSet()
attributedTextRepresentation.enumerateAttribute(.abbreviationPriority, in: wholeRange, options: .longestEffectiveRangeNotRequired) { (priority, range, stop) in
if let priority = priority as? Int {
priorities.insert(priority)
}
}
// Progressively abbreviate the attributed text representation, starting with the highest-priority abbreviations.
let availableBounds = source.availableBounds()
for currentPriority in priorities.sorted(by: <) {
// If the attributed text representation already fits, we’re done.
if attributedTextRepresentation.size().width <= availableBounds.width {
break
}
// Look for substrings with the current abbreviation priority and replace them with the embedded abbreviations.
let wholeRange = NSRange(location: 0, length: attributedTextRepresentation.length)
attributedTextRepresentation.enumerateAttribute(.abbreviationPriority, in: wholeRange, options: []) { (priority, range, stop) in
var abbreviationRange = range
if priority as? Int == currentPriority,
let abbreviation = attributedTextRepresentation.attribute(.abbreviation, at: range.location, effectiveRange: &abbreviationRange) as? String {
assert(abbreviationRange == range, "Abbreviation and abbreviation priority should be applied to the same effective range.")
attributedTextRepresentation.replaceCharacters(in: abbreviationRange, with: abbreviation)
}
}
}
return attributedTextRepresentation
}
func attributedTextRepresentation(of instruction: VisualInstruction, dataSource: DataSource, imageRepository: ImageRepository, onImageDownload: @escaping ImageDownloadCompletion) -> NSAttributedString {
var components = instruction.components
let isShield: (_ key: VisualInstruction.Component?) -> Bool = { (component) in
guard let key = component?.cacheKey else { return false }
return imageRepository.cachedImageForKey(key) != nil
}
components.removeSeparators { (precedingComponent, component, followingComponent) -> Bool in
if case .exit(_) = component {
// Remove exit components, which appear next to exit code components. Exit code components can be styled unambiguously, making the exit component redundant.
return true
} else if isShield(precedingComponent), case .delimiter(_) = component, isShield(followingComponent) {
// Remove delimiter components flanked by image components, which the response includes only for backwards compatibility with text-only clients.
return true
} else {
return false
}
}
let defaultAttributes: [NSAttributedString.Key: Any] = [
.font: dataSource.font as Any,
.foregroundColor: dataSource.textColor as Any
]
let attributedTextRepresentations = components.map { (component) -> NSAttributedString in
switch component {
case .delimiter(let text):
return NSAttributedString(string: text.text, attributes: defaultAttributes)
case .text(let text):
let attributedString = NSMutableAttributedString(string: text.text, attributes: defaultAttributes)
// Annotate the attributed text representation with an abbreviation.
if let abbreviation = text.abbreviation, let abbreviationPriority = text.abbreviationPriority {
let wholeRange = NSRange(location: 0, length: attributedString.length)
attributedString.addAttributes([
.abbreviation: abbreviation,
.abbreviationPriority: abbreviationPriority,
], range: wholeRange)
}
return attributedString
case .image(let image, let alternativeText):
// Ideally represent the image component as a shield image.
return self.attributedString(forShieldComponent: image, repository: imageRepository, dataSource: dataSource, cacheKey: component.cacheKey!, onImageDownload: onImageDownload)
// Fall back to a generic shield if no shield image is available.
?? genericShield(text: alternativeText.text, dataSource: dataSource, cacheKey: component.cacheKey!)
// Finally, fall back to a plain text representation if the generic shield couldn’t be rendered.
?? NSAttributedString(string: alternativeText.text, attributes: defaultAttributes)
case .exit(_):
preconditionFailure("Exit components should have been removed above")
case .exitCode(let text):
let exitSide: ExitSide = instruction.maneuverDirection == .left ? .left : .right
return exitShield(side: exitSide, text: text.text, dataSource: dataSource, cacheKey: component.cacheKey!)
?? NSAttributedString(string: text.text, attributes: defaultAttributes)
case .lane(_, _):
preconditionFailure("Lane component has no attributed string representation.")
}
}
let separator = NSAttributedString(string: " ", attributes: defaultAttributes)
return attributedTextRepresentations.joined(separator: separator)
}
func attributedString(forShieldComponent shield: VisualInstruction.Component.ImageRepresentation, repository:ImageRepository, dataSource: DataSource, cacheKey: String, onImageDownload: @escaping ImageDownloadCompletion) -> NSAttributedString? {
//If we have the shield already cached, use that.
if let cachedImage = repository.cachedImageForKey(cacheKey) {
return attributedString(withFont: dataSource.font, shieldImage: cachedImage)
}
// Let's download the shield
shieldImageForComponent(representation: shield, in: repository, cacheKey: cacheKey, completion: onImageDownload)
//Return nothing in the meantime, triggering downstream behavior (generic shield or text)
return nil
}
private func shieldImageForComponent(representation: VisualInstruction.Component.ImageRepresentation, in repository: ImageRepository, cacheKey: String, completion: @escaping ImageDownloadCompletion) {
guard let imageURL = representation.imageURL(scale: VisualInstruction.Component.scale, format: .png) else { return }
repository.imageWithURL(imageURL, cacheKey: cacheKey, completion: completion )
}
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, dataSource: DataSource, cacheKey: String) -> NSAttributedString? {
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)
view.foregroundColor = dataSource.textColor
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, dataSource: DataSource, cacheKey: String) -> NSAttributedString? {
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)
view.foregroundColor = dataSource.textColor
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?) {
guard image != nil else { return }
//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 ?? nil
}
// 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 func +(lhs: CGSize, rhs: CGSize) -> CGSize {
return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
}