├── .gitignore ├── DynamicButtonStack.podspec ├── DynamicButtonStack.swift ├── DynamicButtonStack.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── DynamicButtonStackDemo.xcscheme │ └── DynamicButtonStackDemoAlt.xcscheme ├── DynamicButtonStackDemo ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── DemoViewController.swift └── Info.plist ├── License.txt ├── Package.swift ├── README.md └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata 2 | -------------------------------------------------------------------------------- /DynamicButtonStack.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'DynamicButtonStack' 3 | spec.module_name = 'DynamicButtonStackKit' # Module name must be different from the class name. 4 | spec.version = '1.1.5' 5 | spec.license = { :type => 'MIT', :file => 'License.txt' } 6 | spec.homepage = 'https://github.com/douglashill/DynamicButtonStack' 7 | spec.authors = { 'Douglas Hill' => 'https://twitter.com/qdoug' } 8 | spec.summary = 'A view that dynamically lays out a collection of buttons to suit the button content and the available space.' 9 | 10 | spec.description = <<-DESC 11 | A view for UIKit apps that dynamically lays out a collection of UIButtons in either a column or a row to suit the button content and the available space. 12 | DESC 13 | 14 | spec.source = { :git => 'https://github.com/douglashill/DynamicButtonStack.git', :tag => spec.version.to_s } 15 | spec.swift_version = '5.0' 16 | spec.ios.deployment_target = '13.0' 17 | spec.source_files = 'DynamicButtonStack.swift' 18 | 19 | end 20 | -------------------------------------------------------------------------------- /DynamicButtonStack.swift: -------------------------------------------------------------------------------- 1 | // Douglas Hill, March 2020 2 | 3 | import UIKit 4 | 5 | /// A stack of buttons that dynamically adjusts the layout to fit the content in the available 6 | /// width. The buttons are stacked either horizontally or vertically, and the image and label 7 | /// within each button are also stacked either horizontally or vertically. 8 | /// The height required should be found by calling sizeThatFits and passing in the width limit. 9 | /// The height passed to sizeThatFits should be greatestFiniteMagnitude. 10 | public class DynamicButtonStack: UIView { 11 | 12 | private let internalSpacing: CGFloat = 8 13 | 14 | @objc public var buttons: [UIButton] = [] { 15 | willSet { 16 | for button in buttons { 17 | button.removeFromSuperview() 18 | } 19 | } 20 | didSet { 21 | didSetButtons() 22 | } 23 | } 24 | 25 | /// didSet is not called in an initialiser so this has been extracted. 26 | private func didSetButtons() { 27 | for button in buttons { 28 | button.titleLabel?.numberOfLines = 0 29 | button.titleLabel?.textAlignment = .center 30 | addSubview(button) 31 | } 32 | } 33 | 34 | @objc public convenience init(buttons: [UIButton]) { 35 | self.init(frame: .zero) 36 | 37 | self.buttons = buttons 38 | didSetButtons() 39 | } 40 | 41 | public override init(frame: CGRect) { 42 | super.init(frame: frame) 43 | sharedInit() 44 | } 45 | 46 | public required init?(coder: NSCoder) { 47 | super.init(coder: coder) 48 | sharedInit() 49 | } 50 | 51 | private func sharedInit() { 52 | setContentCompressionResistancePriority(.required, for: .vertical) 53 | } 54 | 55 | private func usualButtonLengthForContainerLength(_ containerLength: CGFloat) -> CGFloat { 56 | precondition(buttons.isEmpty == false) 57 | 58 | let unrounded = (containerLength - CGFloat(buttons.count - 1) * internalSpacing) / CGFloat(buttons.count) 59 | return roundToPixels(unrounded, function: floor) 60 | } 61 | 62 | private func lengthForButtonAtIndex(_ index: Int, withContainerLength containerLength: CGFloat) -> CGFloat { 63 | let usualButtonLength = usualButtonLengthForContainerLength(containerLength) 64 | 65 | // In case the width doesn’t divide cleanly, make the last button slightly bigger to fill the space. 66 | // Reasoning is the buttons are wider than the spacing so the difference will be least noticeable on the button. 67 | // It could be any button really. Choosing the last one is arbitrary, although it makes the implementation of frameForButtonAtIndex a bit simpler. 68 | if index == buttons.count - 1 { 69 | return containerLength - CGFloat(buttons.count - 1) * (usualButtonLength + internalSpacing) 70 | } else { 71 | return usualButtonLength 72 | } 73 | } 74 | 75 | /// Returns the frame where a button should be positioned. 76 | /// - Parameters: 77 | /// - index: The index of the button in the buttons property. 78 | /// - stackingOrientation: The stacking direction of the buttons outside themselves (not of the image and title in the button). 79 | private func frameForButtonAtIndex(_ index: Int, stackingOrientation: UIButton.StackingOrientation) -> CGRect { 80 | switch stackingOrientation { 81 | case .horizontal: 82 | let effectiveIndex = isEffectiveUserInterfaceLayoutDirectionRightToLeft ? buttons.count - (index + 1) : index 83 | return CGRect(x: CGFloat(effectiveIndex) * (usualButtonLengthForContainerLength(bounds.width) + internalSpacing), y: 0, width: lengthForButtonAtIndex(index, withContainerLength: bounds.width), height: bounds.height) 84 | case .vertical: 85 | return CGRect(x: 0, y: CGFloat(index) * (usualButtonLengthForContainerLength(bounds.height) + internalSpacing), width: bounds.width, height: lengthForButtonAtIndex(index, withContainerLength: bounds.height)) 86 | } 87 | } 88 | 89 | public override var frame: CGRect { 90 | didSet { 91 | invalidateIntrinsicContentSizeIfNeededWithOldWidth(oldValue.width) 92 | } 93 | } 94 | 95 | public override var bounds: CGRect { 96 | didSet { 97 | invalidateIntrinsicContentSizeIfNeededWithOldWidth(oldValue.width) 98 | } 99 | } 100 | 101 | private func invalidateIntrinsicContentSizeIfNeededWithOldWidth(_ oldWidth: CGFloat) { 102 | if bounds.width != oldWidth { 103 | // It doesn’t work without this being async. 104 | DispatchQueue.main.async { 105 | self.invalidateIntrinsicContentSize() 106 | } 107 | } 108 | } 109 | 110 | public override var intrinsicContentSize: CGSize { 111 | sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) 112 | } 113 | 114 | public override func sizeThatFits(_ availableSize: CGSize) -> CGSize { 115 | precondition(availableSize.height == .greatestFiniteMagnitude, "\(DynamicButtonStack.self) does not support limiting the available height.") 116 | 117 | if buttons.isEmpty { 118 | return .zero 119 | } 120 | 121 | return requiredSizeForWidth(availableSize.width) 122 | } 123 | 124 | /// Returns the smallest height for a given width. 125 | private func requiredSizeForWidth(_ availableWidth: CGFloat) -> CGSize { 126 | precondition(buttons.isEmpty == false) 127 | 128 | /// Layout info for when the buttons are shown side-by-side, so each button width is a fraction of the container width. 129 | let layoutInfoForHorizontalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in 130 | let buttonWidth = lengthForButtonAtIndex(index, withContainerLength: availableWidth) 131 | return button.layoutInfoForWidth(buttonWidth) 132 | } 133 | 134 | // (1) Try horizontal stacking of the buttons and horizontal stacking in the buttons. 135 | // If any button doesn’t fit, move on. 136 | let allFitHorizontally = layoutInfoForHorizontalStacking.allSatisfy { 137 | switch $0.stackingOrientation { 138 | case .horizontal: return true 139 | case .vertical: return false 140 | } 141 | } 142 | if allFitHorizontally { 143 | // Use the max width rather than the sum to get a more even layout. 144 | let maxWidth = layoutInfoForHorizontalStacking.max(by: { $0.buttonSize.width < $1.buttonSize.width} )!.buttonSize.width 145 | let maxHeight = layoutInfoForHorizontalStacking.max(by: { $0.buttonSize.height < $1.buttonSize.height} )!.buttonSize.height 146 | return CGSize( 147 | width: maxWidth * CGFloat(buttons.count) + internalSpacing * CGFloat(buttons.count - 1), 148 | height: maxHeight 149 | ) 150 | } 151 | 152 | // (2) Try horizontal stacking of the buttons and vertical stacking in the buttons. 153 | let allFitVerticallyWithoutWrapping = layoutInfoForHorizontalStacking.allSatisfy { 154 | switch $0.stackingOrientation { 155 | case .horizontal: return true 156 | case .vertical: return $0.requiresWrapping == false 157 | } 158 | } 159 | if allFitVerticallyWithoutWrapping { 160 | // This is inefficiently recalculating things that may have just been calculated already. This could be addressed if performance is a problem. 161 | let sizes = buttons.enumerated().map { index, button -> CGSize in 162 | return button.sizeForVerticalStackingForWidth(nil) 163 | } 164 | let maxWidth = sizes.max(by: { $0.width < $1.width} )!.width 165 | let maxHeight = sizes.max(by: { $0.height < $1.height} )!.height 166 | return CGSize( 167 | width: maxWidth * CGFloat(buttons.count) + internalSpacing * CGFloat(buttons.count - 1), 168 | height: maxHeight 169 | ) 170 | } 171 | 172 | /// Layout info for when the buttons are shown top-to-bottom, so each button has the full width of the container. 173 | let layoutInfoForVerticalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in 174 | return button.layoutInfoForWidth(availableWidth) 175 | } 176 | 177 | // (3) Try vertical stacking of the buttons and horizontal stacking in the buttons. 178 | let allFitHorizontallyWithVerticalStacking = layoutInfoForVerticalStacking.allSatisfy { 179 | switch $0.stackingOrientation { 180 | case .horizontal: return true 181 | case .vertical: return false 182 | } 183 | } 184 | if allFitHorizontallyWithVerticalStacking { 185 | let maxWidth = layoutInfoForVerticalStacking.max(by: { $0.buttonSize.width < $1.buttonSize.width} )!.buttonSize.width 186 | let maxHeight = layoutInfoForVerticalStacking.max(by: { $0.buttonSize.height < $1.buttonSize.height} )!.buttonSize.height 187 | return CGSize( 188 | width: maxWidth, 189 | height: maxHeight * CGFloat(buttons.count) + internalSpacing * CGFloat(buttons.count - 1) 190 | ) 191 | } 192 | 193 | // (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. 194 | return buttons.enumerated().map { index, button -> CGSize in 195 | return button.sizeForVerticalStackingForWidth(availableWidth) 196 | }.reduce(CGSize(width: 0, height: -internalSpacing)) { buttonSize, accumulator -> CGSize in 197 | CGSize( 198 | width: max(accumulator.width, buttonSize.width), 199 | height: accumulator.height + internalSpacing + buttonSize.height 200 | ) 201 | } 202 | } 203 | 204 | public override func layoutSubviews() { 205 | super.layoutSubviews() 206 | 207 | if buttons.isEmpty { 208 | return 209 | } 210 | 211 | // Try layouts in this order: (3) (4) (2) (1). 212 | // Mostly it makes sense to try from the more expanded layouts to the more compact because when given 213 | // more space than needed it looks best to fill it. 214 | // However (3) looks more balanced than (4) when there is loads of space, so try that first. 215 | 216 | // No attempt is made to fit within the width. 217 | // It is assumed that sizeThatFits was used correctly and sufficient space has been given. 218 | 219 | // First try stacking the buttons vertically and the image and title within each button horizontally (3). 220 | switch layoutVerticalHorizontal() { 221 | case .fits: 222 | return 223 | case .notEnoughWidth: 224 | // Use fully vertical stacking (4). 225 | layoutVerticalVertical() 226 | return 227 | case .notEnoughHeight: 228 | // Go on to (2) and (1). 229 | break 230 | } 231 | 232 | // (2) 233 | if layoutHorizontalVertical() { 234 | return 235 | } 236 | 237 | // (1) 238 | layoutHorizontalHorizontal() 239 | } 240 | 241 | /// This does not check if the buttons fit in the bounds height. They will be proportionally scaled to fit if needed. 242 | private func layoutVerticalVertical() { 243 | // In this mode, each button label can use multiple lines so each button will be as tall as it needs to be. 244 | 245 | let allInternalSizes = buttons.map { button -> UIButton.InternalSizes in 246 | button.internalSizesForVerticalStackingForWidth(bounds.width) 247 | } 248 | 249 | let fittingHeights = zip(buttons, allInternalSizes).map { (button, internalSizes) -> CGFloat in 250 | button.buttonSizeForContentSize(internalSizes.contentSize).height 251 | } 252 | 253 | let totalFittingHeightOfButtons = fittingHeights.reduce(0) { $0 + $1 } 254 | 255 | let availableHeightForButtons = bounds.height - internalSpacing * CGFloat(buttons.count - 1) 256 | 257 | /// Used to scale up the height of each button proportionally when the space available is more or less than the space needed. 258 | let heightScale = availableHeightForButtons / totalFittingHeightOfButtons 259 | 260 | var unroundedOriginY: CGFloat = 0 261 | for ((button, internalSizes), fittingHeight) in zip(zip(buttons, allInternalSizes), fittingHeights) { 262 | let originY = roundToPixels(unroundedOriginY) 263 | let unroundedHeight = fittingHeight * heightScale 264 | let height: CGFloat 265 | 266 | if button === buttons.last { 267 | // Ensure the last one ends exactly at the bottom of the container just in case. 268 | height = bounds.height - originY 269 | } else { 270 | height = roundToPixels(unroundedHeight) 271 | } 272 | 273 | button.frame = CGRect(x: 0, y: originY, width: bounds.width, height: height) 274 | 275 | button.updateEdgeInsetsForStackingOrientation(.vertical, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: nil, largestTitleLength: nil) 276 | 277 | unroundedOriginY += unroundedHeight + internalSpacing 278 | } 279 | } 280 | 281 | private enum LayoutFitting { 282 | case notEnoughWidth 283 | case notEnoughHeight 284 | case fits 285 | } 286 | 287 | private func layoutVerticalHorizontal() -> LayoutFitting { 288 | /// Info for the buttons being stacked vertically. 289 | let layoutInfoForOuterVerticalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in 290 | return button.layoutInfoForWidth(bounds.width) 291 | } 292 | 293 | let allFitHorizontallyWithVerticalStacking = layoutInfoForOuterVerticalStacking.allSatisfy { 294 | switch $0.stackingOrientation { 295 | case .horizontal: return true 296 | case .vertical: return false 297 | } 298 | } 299 | if allFitHorizontallyWithVerticalStacking == false { 300 | return .notEnoughWidth 301 | } 302 | 303 | let totalFittingHeightOfButtons = layoutInfoForOuterVerticalStacking.reduce(0) { $0 + $1.buttonSize.height } 304 | let availableHeightForButtons = bounds.height - internalSpacing * CGFloat(buttons.count - 1) 305 | 306 | guard totalFittingHeightOfButtons <= availableHeightForButtons else { 307 | return .notEnoughHeight 308 | } 309 | 310 | let widestImageWidth = layoutInfoForOuterVerticalStacking.max(by: { $0.internalSizes.imageSize.width < $1.internalSizes.imageSize.width} )!.internalSizes.imageSize.width 311 | let widestTitleWidth = layoutInfoForOuterVerticalStacking.max(by: { $0.internalSizes.titleSize.width < $1.internalSizes.titleSize.width} )!.internalSizes.titleSize.width 312 | 313 | for (index, button) in buttons.enumerated() { 314 | button.frame = frameForButtonAtIndex(index, stackingOrientation: .vertical) 315 | let info = layoutInfoForOuterVerticalStacking[index] 316 | button.updateEdgeInsetsForStackingOrientation(.horizontal, imageSize: info.internalSizes.imageSize, titleSize: info.internalSizes.titleSize, largestImageLength: widestImageWidth, largestTitleLength: widestTitleWidth) 317 | } 318 | 319 | return .fits 320 | } 321 | 322 | /// Returns true if the buttons fit in the bounds height. 323 | private func layoutHorizontalVertical() -> Bool { 324 | let allInternalSizes = buttons.enumerated().map { index, button -> UIButton.InternalSizes in 325 | let buttonWidth = lengthForButtonAtIndex(index, withContainerLength: bounds.width) 326 | return button.internalSizesForVerticalStackingForWidth(buttonWidth) 327 | } 328 | 329 | let fittingHeights = zip(buttons, allInternalSizes).map { (button, internalSizes) -> CGFloat in 330 | button.buttonSizeForContentSize(internalSizes.contentSize).height 331 | } 332 | 333 | let allFitVertically = fittingHeights.allSatisfy { fittingHeight -> Bool in 334 | fittingHeight <= bounds.height 335 | } 336 | 337 | guard allFitVertically else { 338 | return false 339 | } 340 | 341 | let tallestImageHeight = allInternalSizes.max(by: { $0.imageSize.height < $1.imageSize.height} )!.imageSize.height 342 | let tallestTitleHeight = allInternalSizes.max(by: { $0.titleSize.height < $1.titleSize.height} )!.titleSize.height 343 | 344 | for (index, button) in buttons.enumerated() { 345 | button.frame = frameForButtonAtIndex(index, stackingOrientation: .horizontal) 346 | let internalSizes = allInternalSizes[index] 347 | button.updateEdgeInsetsForStackingOrientation(.vertical, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: tallestImageHeight, largestTitleLength: tallestTitleHeight) 348 | } 349 | 350 | return true 351 | } 352 | 353 | private func layoutHorizontalHorizontal() { 354 | for (index, button) in buttons.enumerated() { 355 | button.frame = frameForButtonAtIndex(index, stackingOrientation: .horizontal) 356 | let internalSizes = button.internalSizesForHorizontalStacking 357 | button.updateEdgeInsetsForStackingOrientation(.horizontal, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: nil, largestTitleLength: nil) 358 | } 359 | } 360 | } 361 | 362 | private extension UIButton { 363 | 364 | enum StackingOrientation { 365 | case horizontal 366 | case vertical 367 | } 368 | 369 | struct InternalSizes { 370 | let contentSize: CGSize 371 | let imageSize: CGSize 372 | let titleSize: CGSize 373 | } 374 | 375 | struct LayoutInfo { 376 | let stackingOrientation: StackingOrientation 377 | let requiresWrapping: Bool 378 | let buttonSize: CGSize 379 | let internalSizes: InternalSizes 380 | } 381 | 382 | private var halfInternalSpacing: CGFloat { 383 | round(0.3 * (titleLabel?.font.pointSize ?? 0)) 384 | } 385 | 386 | func availableContentWidthForAvailableWidth(_ availableWidth: CGFloat) -> CGFloat { 387 | availableWidth - (contentEdgeInsets.left + contentEdgeInsets.right) 388 | } 389 | 390 | func layoutInfoForWidth(_ availableWidth: CGFloat) -> LayoutInfo { 391 | let internalSizes = internalSizesForHorizontalStacking 392 | let requiredContentSizeForHorizontalStacking = internalSizes.contentSize 393 | let imageSize = internalSizes.imageSize 394 | var titleSize = internalSizes.titleSize 395 | 396 | let availableContentWidth = availableContentWidthForAvailableWidth(availableWidth) 397 | 398 | let stackingOrientation: StackingOrientation 399 | let requiresWrapping: Bool 400 | let requiredContentSize: CGSize 401 | 402 | if requiredContentSizeForHorizontalStacking.width <= availableContentWidth { 403 | stackingOrientation = .horizontal 404 | requiresWrapping = false 405 | requiredContentSize = requiredContentSizeForHorizontalStacking 406 | } else if titleSize.width <= availableContentWidth { 407 | stackingOrientation = .vertical 408 | requiresWrapping = false 409 | // Below it’s duplicating code to avoid repeating the measurements. Should be the same as from 410 | // internalSizesForVerticalStackingForWidth since we know the title fits without wrapping. 411 | requiredContentSize = CGSize( 412 | width: max(imageSize.width, titleSize.height), 413 | height: imageSize.height + 2 * halfInternalSpacing + titleSize.height 414 | ) 415 | } else { 416 | stackingOrientation = .vertical 417 | requiresWrapping = true 418 | let internalSizesForVerticalStacking = internalSizesForVerticalStackingForWidth(availableWidth) 419 | requiredContentSize = internalSizesForVerticalStacking.contentSize 420 | titleSize = internalSizesForVerticalStacking.titleSize 421 | } 422 | 423 | return LayoutInfo( 424 | stackingOrientation: stackingOrientation, 425 | requiresWrapping: requiresWrapping, 426 | buttonSize: buttonSizeForContentSize(requiredContentSize), 427 | internalSizes: InternalSizes( 428 | contentSize: requiredContentSize, 429 | imageSize: imageSize, 430 | titleSize: titleSize 431 | ) 432 | ) 433 | } 434 | 435 | /// 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). 436 | var internalSizesForHorizontalStacking: InternalSizes { 437 | let unlimitedSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) 438 | let imageSize = imageView?.sizeThatFits(unlimitedSize) ?? .zero 439 | let titleSize = titleLabel?.sizeThatFits(unlimitedSize) ?? .zero 440 | 441 | return InternalSizes( 442 | contentSize: CGSize( 443 | width: imageSize.width + 2 * halfInternalSpacing + titleSize.width, 444 | height: max(imageSize.height, titleSize.height) 445 | ), 446 | imageSize: imageSize, 447 | titleSize: titleSize 448 | ) 449 | } 450 | 451 | /// 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). 452 | func internalSizesForVerticalStackingForWidth(_ availableWidth: CGFloat?) -> InternalSizes { 453 | let availableContentWidth = availableWidth != nil ? availableContentWidthForAvailableWidth(availableWidth!) : CGFloat.greatestFiniteMagnitude 454 | 455 | let restrictedSize = CGSize(width: availableContentWidth, height: CGFloat.greatestFiniteMagnitude) 456 | let titleSizeWithWrapping = titleLabel?.sizeThatFits(restrictedSize) ?? .zero 457 | 458 | let unlimitedSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) 459 | let imageSize = imageView?.sizeThatFits(unlimitedSize) ?? .zero 460 | 461 | return InternalSizes( 462 | contentSize: CGSize( 463 | width: max(imageSize.width, titleSizeWithWrapping.width), 464 | height: imageSize.height + 2 * halfInternalSpacing + titleSizeWithWrapping.height 465 | ), 466 | imageSize: imageSize, 467 | titleSize: titleSizeWithWrapping 468 | ) 469 | } 470 | 471 | /// 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. 472 | func buttonSizeForContentSize(_ contentSize: CGSize) -> CGSize { 473 | /// Minimum recommend touch target size. 474 | let minLength: CGFloat = 44 475 | return CGSize( 476 | width: max(minLength, contentSize.width + contentEdgeInsets.left + contentEdgeInsets.right), 477 | height: max(minLength, contentSize.height + contentEdgeInsets.top + contentEdgeInsets.bottom) 478 | ) 479 | } 480 | 481 | /// The minimum size for the button fitting within the given width when the image and title are stacked vertically (image above title). 482 | func sizeForVerticalStackingForWidth(_ availableWidth: CGFloat?) -> CGSize { 483 | let internalSizes = internalSizesForVerticalStackingForWidth(availableWidth) 484 | return buttonSizeForContentSize(internalSizes.contentSize) 485 | } 486 | 487 | func updateEdgeInsetsForStackingOrientation(_ stackingOrientation: StackingOrientation, imageSize: CGSize, titleSize: CGSize, largestImageLength: CGFloat?, largestTitleLength: CGFloat?) { 488 | switch stackingOrientation { 489 | 490 | case .horizontal: 491 | let extraImageShift: CGFloat 492 | let extraTitleShift: CGFloat 493 | if let largestTitleLength = largestTitleLength, let largestImageLength = largestImageLength { 494 | extraImageShift = 0.5 * (largestTitleLength - titleSize.width) 495 | extraTitleShift = 0.5 * (largestTitleLength - titleSize.width - largestImageLength + imageSize.width) 496 | } else { 497 | extraImageShift = 0 498 | extraTitleShift = 0 499 | } 500 | 501 | imageEdgeInsets = UIEdgeInsets(view: self, top: 0, leading: -extraImageShift, bottom: 0, trailing: halfInternalSpacing + extraImageShift) 502 | titleEdgeInsets = UIEdgeInsets(view: self, top: 0, leading: halfInternalSpacing - extraTitleShift, bottom: 0, trailing: extraTitleShift) 503 | 504 | case .vertical: 505 | let extraImageShift: CGFloat 506 | let extraTitleShift: CGFloat 507 | if let largestTitleLength = largestTitleLength, let largestImageLength = largestImageLength { 508 | extraImageShift = 0.5 * (largestTitleLength - titleSize.height) 509 | extraTitleShift = 0.5 * (largestTitleLength - titleSize.height - largestImageLength + imageSize.height) 510 | } else { 511 | extraImageShift = 0 512 | extraTitleShift = 0 513 | } 514 | 515 | imageEdgeInsets = UIEdgeInsets(view: self, top: -extraImageShift, leading: 0, bottom: titleSize.height + halfInternalSpacing + extraImageShift, trailing: -titleSize.width) 516 | titleEdgeInsets = UIEdgeInsets(view: self, top: imageSize.height + halfInternalSpacing - extraTitleShift, leading: -imageSize.width, bottom: extraTitleShift, trailing: 0) 517 | } 518 | } 519 | } 520 | 521 | private extension UIEdgeInsets { 522 | /// Maps leading and trailing to right and left for right-to-left layout. 523 | @MainActor init(view: UIView, top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { 524 | let left: CGFloat 525 | let right: CGFloat 526 | 527 | if view.isEffectiveUserInterfaceLayoutDirectionRightToLeft { 528 | left = trailing 529 | right = leading 530 | } else { 531 | left = leading 532 | right = trailing 533 | } 534 | 535 | self.init(top: top, left: left, bottom: bottom, right: right) 536 | } 537 | } 538 | 539 | private extension UIView { 540 | var isEffectiveUserInterfaceLayoutDirectionRightToLeft: Bool { 541 | switch effectiveUserInterfaceLayoutDirection { 542 | case .rightToLeft: 543 | return true 544 | case .leftToRight: fallthrough @unknown default: 545 | return false 546 | } 547 | } 548 | 549 | func roundToPixels(_ unrounded: CGFloat, function: (CGFloat) -> CGFloat = round) -> CGFloat { 550 | let scale = window?.screen.scale ?? 1 551 | return roundToPrecision(unrounded, precision: 1 / scale, function: function) 552 | } 553 | } 554 | 555 | private func roundToPrecision(_ unrounded: T, precision: T, function: (T) -> T) -> T where T : FloatingPoint { 556 | function(unrounded / precision) * precision 557 | } 558 | -------------------------------------------------------------------------------- /DynamicButtonStack.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A55A4F20241968A800AA6405 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55A4F1F241968A800AA6405 /* AppDelegate.swift */; }; 11 | A55A4F24241968A800AA6405 /* DemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55A4F23241968A800AA6405 /* DemoViewController.swift */; }; 12 | A55A4F29241968AB00AA6405 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A55A4F28241968AB00AA6405 /* Assets.xcassets */; }; 13 | A55A4F2C241968AB00AA6405 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A55A4F2A241968AB00AA6405 /* LaunchScreen.storyboard */; }; 14 | A55A4F34241968BC00AA6405 /* DynamicButtonStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55A4F33241968BC00AA6405 /* DynamicButtonStack.swift */; }; 15 | /* End PBXBuildFile section */ 16 | 17 | /* Begin PBXFileReference section */ 18 | A55A4F1C241968A800AA6405 /* DynamicButtonStackDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicButtonStackDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 19 | A55A4F1F241968A800AA6405 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 20 | A55A4F23241968A800AA6405 /* DemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoViewController.swift; sourceTree = ""; }; 21 | A55A4F28241968AB00AA6405 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 22 | A55A4F2B241968AB00AA6405 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 23 | A55A4F2D241968AB00AA6405 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 24 | A55A4F33241968BC00AA6405 /* DynamicButtonStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DynamicButtonStack.swift; sourceTree = ""; }; 25 | A5670B7F24BB09D700646FC4 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 26 | A58FBAD3244327DF006EF941 /* DynamicButtonStack.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = DynamicButtonStack.podspec; sourceTree = ""; }; 27 | A58FBAD4244327DF006EF941 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 28 | /* End PBXFileReference section */ 29 | 30 | /* Begin PBXFrameworksBuildPhase section */ 31 | A55A4F19241968A800AA6405 /* Frameworks */ = { 32 | isa = PBXFrameworksBuildPhase; 33 | buildActionMask = 2147483647; 34 | files = ( 35 | ); 36 | runOnlyForDeploymentPostprocessing = 0; 37 | }; 38 | /* End PBXFrameworksBuildPhase section */ 39 | 40 | /* Begin PBXGroup section */ 41 | A55A4F13241968A800AA6405 = { 42 | isa = PBXGroup; 43 | children = ( 44 | A58FBAD4244327DF006EF941 /* README.md */, 45 | A5670B7F24BB09D700646FC4 /* Package.swift */, 46 | A58FBAD3244327DF006EF941 /* DynamicButtonStack.podspec */, 47 | A55A4F33241968BC00AA6405 /* DynamicButtonStack.swift */, 48 | A55A4F1E241968A800AA6405 /* DynamicButtonStackDemo */, 49 | A55A4F1D241968A800AA6405 /* Products */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | A55A4F1D241968A800AA6405 /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | A55A4F1C241968A800AA6405 /* DynamicButtonStackDemo.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | A55A4F1E241968A800AA6405 /* DynamicButtonStackDemo */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | A55A4F1F241968A800AA6405 /* AppDelegate.swift */, 65 | A55A4F23241968A800AA6405 /* DemoViewController.swift */, 66 | A55A4F28241968AB00AA6405 /* Assets.xcassets */, 67 | A55A4F2A241968AB00AA6405 /* LaunchScreen.storyboard */, 68 | A55A4F2D241968AB00AA6405 /* Info.plist */, 69 | ); 70 | path = DynamicButtonStackDemo; 71 | sourceTree = ""; 72 | }; 73 | /* End PBXGroup section */ 74 | 75 | /* Begin PBXNativeTarget section */ 76 | A55A4F1B241968A800AA6405 /* DynamicButtonStackDemo */ = { 77 | isa = PBXNativeTarget; 78 | buildConfigurationList = A55A4F30241968AB00AA6405 /* Build configuration list for PBXNativeTarget "DynamicButtonStackDemo" */; 79 | buildPhases = ( 80 | A55A4F18241968A800AA6405 /* Sources */, 81 | A55A4F19241968A800AA6405 /* Frameworks */, 82 | A55A4F1A241968A800AA6405 /* Resources */, 83 | ); 84 | buildRules = ( 85 | ); 86 | dependencies = ( 87 | ); 88 | name = DynamicButtonStackDemo; 89 | productName = DynamicButtonStackDemo; 90 | productReference = A55A4F1C241968A800AA6405 /* DynamicButtonStackDemo.app */; 91 | productType = "com.apple.product-type.application"; 92 | }; 93 | /* End PBXNativeTarget section */ 94 | 95 | /* Begin PBXProject section */ 96 | A55A4F14241968A800AA6405 /* Project object */ = { 97 | isa = PBXProject; 98 | attributes = { 99 | BuildIndependentTargetsInParallel = YES; 100 | LastSwiftUpdateCheck = 1130; 101 | LastUpgradeCheck = 1200; 102 | ORGANIZATIONNAME = "Douglas Hill"; 103 | TargetAttributes = { 104 | A55A4F1B241968A800AA6405 = { 105 | CreatedOnToolsVersion = 11.3.1; 106 | }; 107 | }; 108 | }; 109 | buildConfigurationList = A55A4F17241968A800AA6405 /* Build configuration list for PBXProject "DynamicButtonStack" */; 110 | compatibilityVersion = "Xcode 9.3"; 111 | developmentRegion = en; 112 | hasScannedForEncodings = 0; 113 | knownRegions = ( 114 | en, 115 | Base, 116 | ); 117 | mainGroup = A55A4F13241968A800AA6405; 118 | productRefGroup = A55A4F1D241968A800AA6405 /* Products */; 119 | projectDirPath = ""; 120 | projectRoot = ""; 121 | targets = ( 122 | A55A4F1B241968A800AA6405 /* DynamicButtonStackDemo */, 123 | ); 124 | }; 125 | /* End PBXProject section */ 126 | 127 | /* Begin PBXResourcesBuildPhase section */ 128 | A55A4F1A241968A800AA6405 /* Resources */ = { 129 | isa = PBXResourcesBuildPhase; 130 | buildActionMask = 2147483647; 131 | files = ( 132 | A55A4F2C241968AB00AA6405 /* LaunchScreen.storyboard in Resources */, 133 | A55A4F29241968AB00AA6405 /* Assets.xcassets in Resources */, 134 | ); 135 | runOnlyForDeploymentPostprocessing = 0; 136 | }; 137 | /* End PBXResourcesBuildPhase section */ 138 | 139 | /* Begin PBXSourcesBuildPhase section */ 140 | A55A4F18241968A800AA6405 /* Sources */ = { 141 | isa = PBXSourcesBuildPhase; 142 | buildActionMask = 2147483647; 143 | files = ( 144 | A55A4F24241968A800AA6405 /* DemoViewController.swift in Sources */, 145 | A55A4F20241968A800AA6405 /* AppDelegate.swift in Sources */, 146 | A55A4F34241968BC00AA6405 /* DynamicButtonStack.swift in Sources */, 147 | ); 148 | runOnlyForDeploymentPostprocessing = 0; 149 | }; 150 | /* End PBXSourcesBuildPhase section */ 151 | 152 | /* Begin PBXVariantGroup section */ 153 | A55A4F2A241968AB00AA6405 /* LaunchScreen.storyboard */ = { 154 | isa = PBXVariantGroup; 155 | children = ( 156 | A55A4F2B241968AB00AA6405 /* Base */, 157 | ); 158 | name = LaunchScreen.storyboard; 159 | sourceTree = ""; 160 | }; 161 | /* End PBXVariantGroup section */ 162 | 163 | /* Begin XCBuildConfiguration section */ 164 | A55A4F2E241968AB00AA6405 /* Debug */ = { 165 | isa = XCBuildConfiguration; 166 | buildSettings = { 167 | ALWAYS_SEARCH_USER_PATHS = NO; 168 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 169 | CLANG_ANALYZER_NONNULL = YES; 170 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 171 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 172 | CLANG_CXX_LIBRARY = "libc++"; 173 | CLANG_ENABLE_MODULES = YES; 174 | CLANG_ENABLE_OBJC_ARC = YES; 175 | CLANG_ENABLE_OBJC_WEAK = YES; 176 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 177 | CLANG_WARN_BOOL_CONVERSION = YES; 178 | CLANG_WARN_COMMA = YES; 179 | CLANG_WARN_CONSTANT_CONVERSION = YES; 180 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 181 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 182 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 183 | CLANG_WARN_EMPTY_BODY = YES; 184 | CLANG_WARN_ENUM_CONVERSION = YES; 185 | CLANG_WARN_INFINITE_RECURSION = YES; 186 | CLANG_WARN_INT_CONVERSION = YES; 187 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 188 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 189 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 190 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 191 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 192 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 193 | CLANG_WARN_STRICT_PROTOTYPES = YES; 194 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 195 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 196 | CLANG_WARN_UNREACHABLE_CODE = YES; 197 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 198 | COPY_PHASE_STRIP = NO; 199 | DEBUG_INFORMATION_FORMAT = dwarf; 200 | ENABLE_STRICT_OBJC_MSGSEND = YES; 201 | ENABLE_TESTABILITY = YES; 202 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 203 | GCC_C_LANGUAGE_STANDARD = gnu11; 204 | GCC_DYNAMIC_NO_PIC = NO; 205 | GCC_NO_COMMON_BLOCKS = YES; 206 | GCC_OPTIMIZATION_LEVEL = 0; 207 | GCC_PREPROCESSOR_DEFINITIONS = ( 208 | "DEBUG=1", 209 | "$(inherited)", 210 | ); 211 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 212 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 213 | GCC_WARN_UNDECLARED_SELECTOR = YES; 214 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 215 | GCC_WARN_UNUSED_FUNCTION = YES; 216 | GCC_WARN_UNUSED_VARIABLE = YES; 217 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 218 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 219 | MTL_FAST_MATH = YES; 220 | ONLY_ACTIVE_ARCH = YES; 221 | SDKROOT = iphoneos; 222 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 223 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 224 | SWIFT_STRICT_CONCURRENCY = complete; 225 | }; 226 | name = Debug; 227 | }; 228 | A55A4F2F241968AB00AA6405 /* Release */ = { 229 | isa = XCBuildConfiguration; 230 | buildSettings = { 231 | ALWAYS_SEARCH_USER_PATHS = NO; 232 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 233 | CLANG_ANALYZER_NONNULL = YES; 234 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 235 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 236 | CLANG_CXX_LIBRARY = "libc++"; 237 | CLANG_ENABLE_MODULES = YES; 238 | CLANG_ENABLE_OBJC_ARC = YES; 239 | CLANG_ENABLE_OBJC_WEAK = YES; 240 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 241 | CLANG_WARN_BOOL_CONVERSION = YES; 242 | CLANG_WARN_COMMA = YES; 243 | CLANG_WARN_CONSTANT_CONVERSION = YES; 244 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 245 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 246 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 247 | CLANG_WARN_EMPTY_BODY = YES; 248 | CLANG_WARN_ENUM_CONVERSION = YES; 249 | CLANG_WARN_INFINITE_RECURSION = YES; 250 | CLANG_WARN_INT_CONVERSION = YES; 251 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 252 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 253 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 254 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 255 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 256 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 257 | CLANG_WARN_STRICT_PROTOTYPES = YES; 258 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 259 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 260 | CLANG_WARN_UNREACHABLE_CODE = YES; 261 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 262 | COPY_PHASE_STRIP = NO; 263 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 264 | ENABLE_NS_ASSERTIONS = NO; 265 | ENABLE_STRICT_OBJC_MSGSEND = YES; 266 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 267 | GCC_C_LANGUAGE_STANDARD = gnu11; 268 | GCC_NO_COMMON_BLOCKS = YES; 269 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 270 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 271 | GCC_WARN_UNDECLARED_SELECTOR = YES; 272 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 273 | GCC_WARN_UNUSED_FUNCTION = YES; 274 | GCC_WARN_UNUSED_VARIABLE = YES; 275 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 276 | MTL_ENABLE_DEBUG_INFO = NO; 277 | MTL_FAST_MATH = YES; 278 | SDKROOT = iphoneos; 279 | SWIFT_COMPILATION_MODE = wholemodule; 280 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 281 | SWIFT_STRICT_CONCURRENCY = complete; 282 | VALIDATE_PRODUCT = YES; 283 | }; 284 | name = Release; 285 | }; 286 | A55A4F31241968AB00AA6405 /* Debug */ = { 287 | isa = XCBuildConfiguration; 288 | buildSettings = { 289 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 290 | CODE_SIGN_STYLE = Automatic; 291 | DEVELOPMENT_TEAM = 3A8QT46Z78; 292 | INFOPLIST_FILE = DynamicButtonStackDemo/Info.plist; 293 | LD_RUNPATH_SEARCH_PATHS = ( 294 | "$(inherited)", 295 | "@executable_path/Frameworks", 296 | ); 297 | PRODUCT_BUNDLE_IDENTIFIER = co.douglashill.DynamicButtonStackDemo; 298 | PRODUCT_NAME = "$(TARGET_NAME)"; 299 | SWIFT_VERSION = 5.0; 300 | TARGETED_DEVICE_FAMILY = "1,2"; 301 | }; 302 | name = Debug; 303 | }; 304 | A55A4F32241968AB00AA6405 /* Release */ = { 305 | isa = XCBuildConfiguration; 306 | buildSettings = { 307 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 308 | CODE_SIGN_STYLE = Automatic; 309 | DEVELOPMENT_TEAM = 3A8QT46Z78; 310 | INFOPLIST_FILE = DynamicButtonStackDemo/Info.plist; 311 | LD_RUNPATH_SEARCH_PATHS = ( 312 | "$(inherited)", 313 | "@executable_path/Frameworks", 314 | ); 315 | PRODUCT_BUNDLE_IDENTIFIER = co.douglashill.DynamicButtonStackDemo; 316 | PRODUCT_NAME = "$(TARGET_NAME)"; 317 | SWIFT_VERSION = 5.0; 318 | TARGETED_DEVICE_FAMILY = "1,2"; 319 | }; 320 | name = Release; 321 | }; 322 | /* End XCBuildConfiguration section */ 323 | 324 | /* Begin XCConfigurationList section */ 325 | A55A4F17241968A800AA6405 /* Build configuration list for PBXProject "DynamicButtonStack" */ = { 326 | isa = XCConfigurationList; 327 | buildConfigurations = ( 328 | A55A4F2E241968AB00AA6405 /* Debug */, 329 | A55A4F2F241968AB00AA6405 /* Release */, 330 | ); 331 | defaultConfigurationIsVisible = 0; 332 | defaultConfigurationName = Release; 333 | }; 334 | A55A4F30241968AB00AA6405 /* Build configuration list for PBXNativeTarget "DynamicButtonStackDemo" */ = { 335 | isa = XCConfigurationList; 336 | buildConfigurations = ( 337 | A55A4F31241968AB00AA6405 /* Debug */, 338 | A55A4F32241968AB00AA6405 /* Release */, 339 | ); 340 | defaultConfigurationIsVisible = 0; 341 | defaultConfigurationName = Release; 342 | }; 343 | /* End XCConfigurationList section */ 344 | }; 345 | rootObject = A55A4F14241968A800AA6405 /* Project object */; 346 | } 347 | -------------------------------------------------------------------------------- /DynamicButtonStack.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DynamicButtonStack.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DynamicButtonStack.xcodeproj/xcshareddata/xcschemes/DynamicButtonStackDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /DynamicButtonStack.xcodeproj/xcshareddata/xcschemes/DynamicButtonStackDemoAlt.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /DynamicButtonStackDemo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // Douglas Hill, March 2020 2 | 3 | import UIKit 4 | 5 | @UIApplicationMain 6 | class AppDelegate: UIResponder, UIApplicationDelegate { 7 | var _window: UIWindow? 8 | 9 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 10 | let window = UIWindow() 11 | window.rootViewController = DemoViewController() 12 | window.makeKeyAndVisible() 13 | _window = window 14 | 15 | return true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /DynamicButtonStackDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /DynamicButtonStackDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /DynamicButtonStackDemo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /DynamicButtonStackDemo/DemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Douglas Hill, March 2020 2 | 3 | import UIKit 4 | 5 | class DemoViewController: UIViewController { 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | 10 | view.backgroundColor = .systemBackground 11 | 12 | let makeButton: (String?, String?) -> UIButton = { imageName, title in 13 | let button = UIButton() 14 | button.setTitle(title, for: .normal) 15 | button.setTitleColor(.label, for: .normal) 16 | button.titleLabel!.font = UIFont.preferredFont(forTextStyle: .body) 17 | if let imageName = imageName { 18 | button.setImage(UIImage(systemName: imageName, withConfiguration: UIImage.SymbolConfiguration(font: button.titleLabel!.font)), for: .normal) 19 | } 20 | button.layer.cornerRadius = 10 21 | button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) 22 | button.backgroundColor = .secondarySystemBackground 23 | return button 24 | } 25 | 26 | let buttonStack1 = DynamicButtonStack(buttons: [ 27 | makeButton("wand.and.stars", "Auto"), 28 | makeButton("bold.italic.underline", "Style"), 29 | makeButton("paperplane", "Send"), 30 | ]) 31 | 32 | let buttonStack2 = DynamicButtonStack(buttons: [ 33 | makeButton("arrowshape.turn.up.right", "回覆"), 34 | makeButton("gear", "設定"), 35 | makeButton("square.and.arrow.up", "分享"), 36 | makeButton("square.and.pencil", "編寫"), 37 | makeButton("trash", "刪除"), 38 | ]) 39 | 40 | let buttonStack3 = DynamicButtonStack(buttons: [ 41 | makeButton("plus", "Hinzufügen"), 42 | makeButton("folder", "Organisieren"), 43 | makeButton("arrow.clockwise", "Aktualisieren"), 44 | ]) 45 | 46 | let buttonStack4 = DynamicButtonStack(buttons: [ 47 | makeButton("person.3", "Collaborate on This Document With Some People"), 48 | makeButton("waveform.path.badge.plus", "Signal Boost"), 49 | makeButton("dot.radiowaves.left.and.right", "Broadcast"), 50 | ]) 51 | 52 | let stackView = UIStackView(arrangedSubviews: [buttonStack1, buttonStack2, buttonStack3, buttonStack4]) 53 | stackView.alignment = .fill 54 | stackView.axis = .vertical 55 | stackView.spacing = 20 56 | stackView.translatesAutoresizingMaskIntoConstraints = false 57 | 58 | let scrollView = UIScrollView() 59 | scrollView.translatesAutoresizingMaskIntoConstraints = false 60 | scrollView.addSubview(stackView) 61 | view.addSubview(scrollView) 62 | 63 | NSLayoutConstraint.activate([ 64 | stackView.leadingAnchor.constraint(equalTo: scrollView.layoutMarginsGuide.leadingAnchor), 65 | stackView.widthAnchor.constraint(equalTo: scrollView.layoutMarginsGuide.widthAnchor), 66 | stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 60), 67 | scrollView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 20), 68 | 69 | view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), 70 | view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 71 | view.topAnchor.constraint(equalTo: scrollView.topAnchor), 72 | view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 73 | ]) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /DynamicButtonStackDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2020 Douglas Hill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "DynamicButtonStack", 7 | platforms: [.iOS(.v13)], 8 | products: [ 9 | .library( 10 | name: "DynamicButtonStack", 11 | targets: ["DynamicButtonStack"] 12 | ), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "DynamicButtonStack", 17 | path: ".", 18 | sources: ["DynamicButtonStack.swift"] 19 | ), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DynamicButtonStack 2 | 3 | DynamicButtonStack lays out a collection of buttons in either a column or a row. It dynamically adjusts the layout to suit the button content and the available space. 4 | 5 | See the blog post to read more about [the problems solved by DynamicButtonStack and the design principles behind it.](https://douglashill.co/dynamic-button-stack/) 6 | 7 | ![Composite screenshot of DynamicButtonStack in various languages. Chinese, English, German, Arabic.](screenshot.png) 8 | 9 | ## Requirements 10 | 11 | - DynamicButtonStack requires iOS 13 or later. 12 | - The latest stable Xcode is expected. 13 | - Works with both Swift and Objective-C apps. 14 | 15 | ## Installation 16 | 17 | ### Direct 18 | 19 | 1. Clone this repository or download [`DynamicButtonStack.swift`](DynamicButtonStack.swift) from GitHub. 20 | 2. Drag this file into your Xcode project and choose to add it to your target when prompted. 21 | 22 | ### Swift Package Manager 23 | 24 | Add DynamicButtonStack to an existing Xcode project as a package dependency: 25 | 26 | 1. From the File menu, select Swift Packages › Add Package Dependency… 27 | 2. Enter `https://github.com/douglashill/DynamicButtonStack` as the package repository URL. 28 | 29 | ### CocoaPods 30 | 31 | [DynamicButtonStack is available on CocoaPods](https://cocoapods.org/pods/DynamicButtonStack) as `DynamicButtonStack`. The module name when using CocoaPods is `DynamicButtonStackKit`. 32 | 33 | ## Usage 34 | 35 | Your app provides a DynamicButtonStack with buttons and a maximum width. The DynamicButtonStack then provides your app the minimum width and height required. You app then gives the DynamicButtonStack at least that amount of space and the buttons will be nicely stacked within that space. 36 | 37 | You can supply as many buttons as you like. Their titles can be as long as you like, and the font can be as large as you like. In exchange, give the button stack the height it needs. Therefore the button stack is typically best placed in a vertically scrolling view. 38 | 39 | Create a `DynamicButtonStack` and give it an array of buttons that each have both an image and a title. Add the button stack to your view hierarchy. 40 | 41 | ```swift 42 | let button = UIButton() 43 | button.setImage(UIImage(systemName: "paperplane"), for: .normal) 44 | button.setTitle("Send", for: .normal) 45 | 46 | buttonStack = DynamicButtonStack(buttons: [ 47 | button, 48 | ]) 49 | 50 | view.addSubview(buttonStack) 51 | ``` 52 | 53 | The button stack can be laid out with either `sizeThatFits` and `layoutSubviews` or using constraints. 54 | 55 | When using constraints, the stack sets its vertical compression resistance priority to required because the buttons may be clipped otherwise. Typically a width constraint should be provided. 56 | 57 | When using `layoutSubviews`, the frame should be set with a size at least as large as the size returned from `sizeThatFits` (in both dimensions). Measure the minimum size using `sizeThatFits`. Pass your container’s width limit and an unlimited height. An assertion will fail if the height is not unlimited. This is a reminder that handling restricted heights is not currently supported. 58 | 59 | ```swift 60 | override func layoutSubviews() { 61 | super.layoutSubviews() 62 | 63 | let availableSize = CGSize(width: bounds.width, height: .greatestFiniteMagnitude) 64 | let requiredSize = buttonStack.sizeThatFits(availableSize) 65 | buttonStack.frame = CGRect(origin: .zero, size: requiredSize) 66 | } 67 | ``` 68 | 69 | The buttons can be styled however you like. Colour, font, shadow, highlight state etc. 70 | 71 | - Set both an image and a title. 72 | - Don’t modify the `imageEdgeInsets` or `titleEdgeInsets` because DynamicButtonStack needs to adjust these to set the stacking and alignment inside the buttons. 73 | - Customise any other properties however you like. Setting `contentEdgeInsets` is recommended. 74 | 75 | ## Status 76 | 77 | ✅ DynamicButtonStack is considered ready for use in production. 78 | 79 | ↔️ Respects both left-to-right and right-to-left layouts. 80 | 81 | 😇 There is no private API use or interference with private subviews. 82 | 83 | ## Q & A 84 | 85 | ### Which scheme should be used for development? 86 | 87 | There are two identical schemes in the Xcode project to [encourage Swift Package Index to build the Swift package instead of trying to build the demo app](https://swiftpackageindex.com/docs/builds#built-how). Therefore it makes no difference which scheme is used for development. 88 | 89 | ## Credits 90 | 91 | DynamicButtonStack is a project from [Douglas Hill](https://douglashill.co/) and was developed for my [reading app](https://douglashill.co/reading-app/). 92 | 93 | ## Licence 94 | 95 | MIT license — see License.txt 96 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douglashill/DynamicButtonStack/83c215affed2b26d02eb79b4dfb569f932a13f05/screenshot.png --------------------------------------------------------------------------------