-
Notifications
You must be signed in to change notification settings - Fork 1
/
DynamicButtonStack.swift
557 lines (464 loc) · 24.6 KB
/
DynamicButtonStack.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
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
// Douglas Hill, March 2020
import UIKit
/// A stack of buttons that dynamically adjusts the layout to fit the content in the available
/// width. The buttons are stacked either horizontally or vertically, and the image and label
/// within each button are also stacked either horizontally or vertically.
/// The height required should be found by calling sizeThatFits and passing in the width limit.
/// The height passed to sizeThatFits should be greatestFiniteMagnitude.
public class DynamicButtonStack: UIView {
private let internalSpacing: CGFloat = 8
@objc public var buttons: [UIButton] = [] {
willSet {
for button in buttons {
button.removeFromSuperview()
}
}
didSet {
didSetButtons()
}
}
/// didSet is not called in an initialiser so this has been extracted.
private func didSetButtons() {
for button in buttons {
button.titleLabel?.numberOfLines = 0
button.titleLabel?.textAlignment = .center
addSubview(button)
}
}
@objc public convenience init(buttons: [UIButton]) {
self.init(frame: .zero)
self.buttons = buttons
didSetButtons()
}
public override init(frame: CGRect) {
super.init(frame: frame)
sharedInit()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
sharedInit()
}
private func sharedInit() {
setContentCompressionResistancePriority(.required, for: .vertical)
}
private func usualButtonLengthForContainerLength(_ containerLength: CGFloat) -> CGFloat {
precondition(buttons.isEmpty == false)
let unrounded = (containerLength - CGFloat(buttons.count - 1) * internalSpacing) / CGFloat(buttons.count)
return roundToPixels(unrounded, function: floor)
}
private func lengthForButtonAtIndex(_ index: Int, withContainerLength containerLength: CGFloat) -> CGFloat {
let usualButtonLength = usualButtonLengthForContainerLength(containerLength)
// In case the width doesn’t divide cleanly, make the last button slightly bigger to fill the space.
// Reasoning is the buttons are wider than the spacing so the difference will be least noticeable on the button.
// It could be any button really. Choosing the last one is arbitrary, although it makes the implementation of frameForButtonAtIndex a bit simpler.
if index == buttons.count - 1 {
return containerLength - CGFloat(buttons.count - 1) * (usualButtonLength + internalSpacing)
} else {
return usualButtonLength
}
}
/// Returns the frame where a button should be positioned.
/// - Parameters:
/// - index: The index of the button in the buttons property.
/// - stackingOrientation: The stacking direction of the buttons outside themselves (not of the image and title in the button).
private func frameForButtonAtIndex(_ index: Int, stackingOrientation: UIButton.StackingOrientation) -> CGRect {
switch stackingOrientation {
case .horizontal:
let effectiveIndex = isEffectiveUserInterfaceLayoutDirectionRightToLeft ? buttons.count - (index + 1) : index
return CGRect(x: CGFloat(effectiveIndex) * (usualButtonLengthForContainerLength(bounds.width) + internalSpacing), y: 0, width: lengthForButtonAtIndex(index, withContainerLength: bounds.width), height: bounds.height)
case .vertical:
return CGRect(x: 0, y: CGFloat(index) * (usualButtonLengthForContainerLength(bounds.height) + internalSpacing), width: bounds.width, height: lengthForButtonAtIndex(index, withContainerLength: bounds.height))
}
}
public override var frame: CGRect {
didSet {
invalidateIntrinsicContentSizeIfNeededWithOldWidth(oldValue.width)
}
}
public override var bounds: CGRect {
didSet {
invalidateIntrinsicContentSizeIfNeededWithOldWidth(oldValue.width)
}
}
private func invalidateIntrinsicContentSizeIfNeededWithOldWidth(_ oldWidth: CGFloat) {
if bounds.width != oldWidth {
// It doesn’t work without this being async.
DispatchQueue.main.async {
self.invalidateIntrinsicContentSize()
}
}
}
public override var intrinsicContentSize: CGSize {
sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude))
}
public override func sizeThatFits(_ availableSize: CGSize) -> CGSize {
precondition(availableSize.height == .greatestFiniteMagnitude, "\(DynamicButtonStack.self) does not support limiting the available height.")
if buttons.isEmpty {
return .zero
}
return requiredSizeForWidth(availableSize.width)
}
/// Returns the smallest height for a given width.
private func requiredSizeForWidth(_ availableWidth: CGFloat) -> CGSize {
precondition(buttons.isEmpty == false)
/// Layout info for when the buttons are shown side-by-side, so each button width is a fraction of the container width.
let layoutInfoForHorizontalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in
let buttonWidth = lengthForButtonAtIndex(index, withContainerLength: availableWidth)
return button.layoutInfoForWidth(buttonWidth)
}
// (1) Try horizontal stacking of the buttons and horizontal stacking in the buttons.
// If any button doesn’t fit, move on.
let allFitHorizontally = layoutInfoForHorizontalStacking.allSatisfy {
switch $0.stackingOrientation {
case .horizontal: return true
case .vertical: return false
}
}
if allFitHorizontally {
// Use the max width rather than the sum to get a more even layout.
let maxWidth = layoutInfoForHorizontalStacking.max(by: { $0.buttonSize.width < $1.buttonSize.width} )!.buttonSize.width
let maxHeight = layoutInfoForHorizontalStacking.max(by: { $0.buttonSize.height < $1.buttonSize.height} )!.buttonSize.height
return CGSize(
width: maxWidth * CGFloat(buttons.count) + internalSpacing * CGFloat(buttons.count - 1),
height: maxHeight
)
}
// (2) Try horizontal stacking of the buttons and vertical stacking in the buttons.
let allFitVerticallyWithoutWrapping = layoutInfoForHorizontalStacking.allSatisfy {
switch $0.stackingOrientation {
case .horizontal: return true
case .vertical: return $0.requiresWrapping == false
}
}
if allFitVerticallyWithoutWrapping {
// This is inefficiently recalculating things that may have just been calculated already. This could be addressed if performance is a problem.
let sizes = buttons.enumerated().map { index, button -> CGSize in
return button.sizeForVerticalStackingForWidth(nil)
}
let maxWidth = sizes.max(by: { $0.width < $1.width} )!.width
let maxHeight = sizes.max(by: { $0.height < $1.height} )!.height
return CGSize(
width: maxWidth * CGFloat(buttons.count) + internalSpacing * CGFloat(buttons.count - 1),
height: maxHeight
)
}
/// Layout info for when the buttons are shown top-to-bottom, so each button has the full width of the container.
let layoutInfoForVerticalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in
return button.layoutInfoForWidth(availableWidth)
}
// (3) Try vertical stacking of the buttons and horizontal stacking in the buttons.
let allFitHorizontallyWithVerticalStacking = layoutInfoForVerticalStacking.allSatisfy {
switch $0.stackingOrientation {
case .horizontal: return true
case .vertical: return false
}
}
if allFitHorizontallyWithVerticalStacking {
let maxWidth = layoutInfoForVerticalStacking.max(by: { $0.buttonSize.width < $1.buttonSize.width} )!.buttonSize.width
let maxHeight = layoutInfoForVerticalStacking.max(by: { $0.buttonSize.height < $1.buttonSize.height} )!.buttonSize.height
return CGSize(
width: maxWidth,
height: maxHeight * CGFloat(buttons.count) + internalSpacing * CGFloat(buttons.count - 1)
)
}
// (4) Go for vertical stacking of the buttons and vertical stacking in the buttons. This is the only case where the labels may use multiple lines.
return buttons.enumerated().map { index, button -> CGSize in
return button.sizeForVerticalStackingForWidth(availableWidth)
}.reduce(CGSize(width: 0, height: -internalSpacing)) { buttonSize, accumulator -> CGSize in
CGSize(
width: max(accumulator.width, buttonSize.width),
height: accumulator.height + internalSpacing + buttonSize.height
)
}
}
public override func layoutSubviews() {
super.layoutSubviews()
if buttons.isEmpty {
return
}
// Try layouts in this order: (3) (4) (2) (1).
// Mostly it makes sense to try from the more expanded layouts to the more compact because when given
// more space than needed it looks best to fill it.
// However (3) looks more balanced than (4) when there is loads of space, so try that first.
// No attempt is made to fit within the width.
// It is assumed that sizeThatFits was used correctly and sufficient space has been given.
// First try stacking the buttons vertically and the image and title within each button horizontally (3).
switch layoutVerticalHorizontal() {
case .fits:
return
case .notEnoughWidth:
// Use fully vertical stacking (4).
layoutVerticalVertical()
return
case .notEnoughHeight:
// Go on to (2) and (1).
break
}
// (2)
if layoutHorizontalVertical() {
return
}
// (1)
layoutHorizontalHorizontal()
}
/// This does not check if the buttons fit in the bounds height. They will be proportionally scaled to fit if needed.
private func layoutVerticalVertical() {
// In this mode, each button label can use multiple lines so each button will be as tall as it needs to be.
let allInternalSizes = buttons.map { button -> UIButton.InternalSizes in
button.internalSizesForVerticalStackingForWidth(bounds.width)
}
let fittingHeights = zip(buttons, allInternalSizes).map { (button, internalSizes) -> CGFloat in
button.buttonSizeForContentSize(internalSizes.contentSize).height
}
let totalFittingHeightOfButtons = fittingHeights.reduce(0) { $0 + $1 }
let availableHeightForButtons = bounds.height - internalSpacing * CGFloat(buttons.count - 1)
/// Used to scale up the height of each button proportionally when the space available is more or less than the space needed.
let heightScale = availableHeightForButtons / totalFittingHeightOfButtons
var unroundedOriginY: CGFloat = 0
for ((button, internalSizes), fittingHeight) in zip(zip(buttons, allInternalSizes), fittingHeights) {
let originY = roundToPixels(unroundedOriginY)
let unroundedHeight = fittingHeight * heightScale
let height: CGFloat
if button === buttons.last {
// Ensure the last one ends exactly at the bottom of the container just in case.
height = bounds.height - originY
} else {
height = roundToPixels(unroundedHeight)
}
button.frame = CGRect(x: 0, y: originY, width: bounds.width, height: height)
button.updateEdgeInsetsForStackingOrientation(.vertical, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: nil, largestTitleLength: nil)
unroundedOriginY += unroundedHeight + internalSpacing
}
}
private enum LayoutFitting {
case notEnoughWidth
case notEnoughHeight
case fits
}
private func layoutVerticalHorizontal() -> LayoutFitting {
/// Info for the buttons being stacked vertically.
let layoutInfoForOuterVerticalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in
return button.layoutInfoForWidth(bounds.width)
}
let allFitHorizontallyWithVerticalStacking = layoutInfoForOuterVerticalStacking.allSatisfy {
switch $0.stackingOrientation {
case .horizontal: return true
case .vertical: return false
}
}
if allFitHorizontallyWithVerticalStacking == false {
return .notEnoughWidth
}
let totalFittingHeightOfButtons = layoutInfoForOuterVerticalStacking.reduce(0) { $0 + $1.buttonSize.height }
let availableHeightForButtons = bounds.height - internalSpacing * CGFloat(buttons.count - 1)
guard totalFittingHeightOfButtons <= availableHeightForButtons else {
return .notEnoughHeight
}
let widestImageWidth = layoutInfoForOuterVerticalStacking.max(by: { $0.internalSizes.imageSize.width < $1.internalSizes.imageSize.width} )!.internalSizes.imageSize.width
let widestTitleWidth = layoutInfoForOuterVerticalStacking.max(by: { $0.internalSizes.titleSize.width < $1.internalSizes.titleSize.width} )!.internalSizes.titleSize.width
for (index, button) in buttons.enumerated() {
button.frame = frameForButtonAtIndex(index, stackingOrientation: .vertical)
let info = layoutInfoForOuterVerticalStacking[index]
button.updateEdgeInsetsForStackingOrientation(.horizontal, imageSize: info.internalSizes.imageSize, titleSize: info.internalSizes.titleSize, largestImageLength: widestImageWidth, largestTitleLength: widestTitleWidth)
}
return .fits
}
/// Returns true if the buttons fit in the bounds height.
private func layoutHorizontalVertical() -> Bool {
let allInternalSizes = buttons.enumerated().map { index, button -> UIButton.InternalSizes in
let buttonWidth = lengthForButtonAtIndex(index, withContainerLength: bounds.width)
return button.internalSizesForVerticalStackingForWidth(buttonWidth)
}
let fittingHeights = zip(buttons, allInternalSizes).map { (button, internalSizes) -> CGFloat in
button.buttonSizeForContentSize(internalSizes.contentSize).height
}
let allFitVertically = fittingHeights.allSatisfy { fittingHeight -> Bool in
fittingHeight <= bounds.height
}
guard allFitVertically else {
return false
}
let tallestImageHeight = allInternalSizes.max(by: { $0.imageSize.height < $1.imageSize.height} )!.imageSize.height
let tallestTitleHeight = allInternalSizes.max(by: { $0.titleSize.height < $1.titleSize.height} )!.titleSize.height
for (index, button) in buttons.enumerated() {
button.frame = frameForButtonAtIndex(index, stackingOrientation: .horizontal)
let internalSizes = allInternalSizes[index]
button.updateEdgeInsetsForStackingOrientation(.vertical, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: tallestImageHeight, largestTitleLength: tallestTitleHeight)
}
return true
}
private func layoutHorizontalHorizontal() {
for (index, button) in buttons.enumerated() {
button.frame = frameForButtonAtIndex(index, stackingOrientation: .horizontal)
let internalSizes = button.internalSizesForHorizontalStacking
button.updateEdgeInsetsForStackingOrientation(.horizontal, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: nil, largestTitleLength: nil)
}
}
}
private extension UIButton {
enum StackingOrientation {
case horizontal
case vertical
}
struct InternalSizes {
let contentSize: CGSize
let imageSize: CGSize
let titleSize: CGSize
}
struct LayoutInfo {
let stackingOrientation: StackingOrientation
let requiresWrapping: Bool
let buttonSize: CGSize
let internalSizes: InternalSizes
}
private var halfInternalSpacing: CGFloat {
round(0.3 * (titleLabel?.font.pointSize ?? 0))
}
func availableContentWidthForAvailableWidth(_ availableWidth: CGFloat) -> CGFloat {
availableWidth - (contentEdgeInsets.left + contentEdgeInsets.right)
}
func layoutInfoForWidth(_ availableWidth: CGFloat) -> LayoutInfo {
let internalSizes = internalSizesForHorizontalStacking
let requiredContentSizeForHorizontalStacking = internalSizes.contentSize
let imageSize = internalSizes.imageSize
var titleSize = internalSizes.titleSize
let availableContentWidth = availableContentWidthForAvailableWidth(availableWidth)
let stackingOrientation: StackingOrientation
let requiresWrapping: Bool
let requiredContentSize: CGSize
if requiredContentSizeForHorizontalStacking.width <= availableContentWidth {
stackingOrientation = .horizontal
requiresWrapping = false
requiredContentSize = requiredContentSizeForHorizontalStacking
} else if titleSize.width <= availableContentWidth {
stackingOrientation = .vertical
requiresWrapping = false
// Below it’s duplicating code to avoid repeating the measurements. Should be the same as from
// internalSizesForVerticalStackingForWidth since we know the title fits without wrapping.
requiredContentSize = CGSize(
width: max(imageSize.width, titleSize.height),
height: imageSize.height + 2 * halfInternalSpacing + titleSize.height
)
} else {
stackingOrientation = .vertical
requiresWrapping = true
let internalSizesForVerticalStacking = internalSizesForVerticalStackingForWidth(availableWidth)
requiredContentSize = internalSizesForVerticalStacking.contentSize
titleSize = internalSizesForVerticalStacking.titleSize
}
return LayoutInfo(
stackingOrientation: stackingOrientation,
requiresWrapping: requiresWrapping,
buttonSize: buttonSizeForContentSize(requiredContentSize),
internalSizes: InternalSizes(
contentSize: requiredContentSize,
imageSize: imageSize,
titleSize: titleSize
)
)
}
/// The size required for the image view, title label, and combination of the two (content size) when the image and title are stacked horizontally (title right of image).
var internalSizesForHorizontalStacking: InternalSizes {
let unlimitedSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
let imageSize = imageView?.sizeThatFits(unlimitedSize) ?? .zero
let titleSize = titleLabel?.sizeThatFits(unlimitedSize) ?? .zero
return InternalSizes(
contentSize: CGSize(
width: imageSize.width + 2 * halfInternalSpacing + titleSize.width,
height: max(imageSize.height, titleSize.height)
),
imageSize: imageSize,
titleSize: titleSize
)
}
/// The size required for the image view, title label, and combination of the two (content size) when the image and title are stacked vertically (image above title).
func internalSizesForVerticalStackingForWidth(_ availableWidth: CGFloat?) -> InternalSizes {
let availableContentWidth = availableWidth != nil ? availableContentWidthForAvailableWidth(availableWidth!) : CGFloat.greatestFiniteMagnitude
let restrictedSize = CGSize(width: availableContentWidth, height: CGFloat.greatestFiniteMagnitude)
let titleSizeWithWrapping = titleLabel?.sizeThatFits(restrictedSize) ?? .zero
let unlimitedSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
let imageSize = imageView?.sizeThatFits(unlimitedSize) ?? .zero
return InternalSizes(
contentSize: CGSize(
width: max(imageSize.width, titleSizeWithWrapping.width),
height: imageSize.height + 2 * halfInternalSpacing + titleSizeWithWrapping.height
),
imageSize: imageSize,
titleSize: titleSizeWithWrapping
)
}
/// The minimum size for the button for the given content size. Content size is the size of the union of the image and title frames.
func buttonSizeForContentSize(_ contentSize: CGSize) -> CGSize {
/// Minimum recommend touch target size.
let minLength: CGFloat = 44
return CGSize(
width: max(minLength, contentSize.width + contentEdgeInsets.left + contentEdgeInsets.right),
height: max(minLength, contentSize.height + contentEdgeInsets.top + contentEdgeInsets.bottom)
)
}
/// The minimum size for the button fitting within the given width when the image and title are stacked vertically (image above title).
func sizeForVerticalStackingForWidth(_ availableWidth: CGFloat?) -> CGSize {
let internalSizes = internalSizesForVerticalStackingForWidth(availableWidth)
return buttonSizeForContentSize(internalSizes.contentSize)
}
func updateEdgeInsetsForStackingOrientation(_ stackingOrientation: StackingOrientation, imageSize: CGSize, titleSize: CGSize, largestImageLength: CGFloat?, largestTitleLength: CGFloat?) {
switch stackingOrientation {
case .horizontal:
let extraImageShift: CGFloat
let extraTitleShift: CGFloat
if let largestTitleLength = largestTitleLength, let largestImageLength = largestImageLength {
extraImageShift = 0.5 * (largestTitleLength - titleSize.width)
extraTitleShift = 0.5 * (largestTitleLength - titleSize.width - largestImageLength + imageSize.width)
} else {
extraImageShift = 0
extraTitleShift = 0
}
imageEdgeInsets = UIEdgeInsets(view: self, top: 0, leading: -extraImageShift, bottom: 0, trailing: halfInternalSpacing + extraImageShift)
titleEdgeInsets = UIEdgeInsets(view: self, top: 0, leading: halfInternalSpacing - extraTitleShift, bottom: 0, trailing: extraTitleShift)
case .vertical:
let extraImageShift: CGFloat
let extraTitleShift: CGFloat
if let largestTitleLength = largestTitleLength, let largestImageLength = largestImageLength {
extraImageShift = 0.5 * (largestTitleLength - titleSize.height)
extraTitleShift = 0.5 * (largestTitleLength - titleSize.height - largestImageLength + imageSize.height)
} else {
extraImageShift = 0
extraTitleShift = 0
}
imageEdgeInsets = UIEdgeInsets(view: self, top: -extraImageShift, leading: 0, bottom: titleSize.height + halfInternalSpacing + extraImageShift, trailing: -titleSize.width)
titleEdgeInsets = UIEdgeInsets(view: self, top: imageSize.height + halfInternalSpacing - extraTitleShift, leading: -imageSize.width, bottom: extraTitleShift, trailing: 0)
}
}
}
private extension UIEdgeInsets {
/// Maps leading and trailing to right and left for right-to-left layout.
@MainActor init(view: UIView, top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) {
let left: CGFloat
let right: CGFloat
if view.isEffectiveUserInterfaceLayoutDirectionRightToLeft {
left = trailing
right = leading
} else {
left = leading
right = trailing
}
self.init(top: top, left: left, bottom: bottom, right: right)
}
}
private extension UIView {
var isEffectiveUserInterfaceLayoutDirectionRightToLeft: Bool {
switch effectiveUserInterfaceLayoutDirection {
case .rightToLeft:
return true
case .leftToRight: fallthrough @unknown default:
return false
}
}
func roundToPixels(_ unrounded: CGFloat, function: (CGFloat) -> CGFloat = round) -> CGFloat {
let scale = window?.screen.scale ?? 1
return roundToPrecision(unrounded, precision: 1 / scale, function: function)
}
}
private func roundToPrecision<T>(_ unrounded: T, precision: T, function: (T) -> T) -> T where T : FloatingPoint {
function(unrounded / precision) * precision
}