This repository has been archived by the owner. It is now read-only.
Permalink
Cannot retrieve contributors at this time
// Copyright 2016 LinkedIn Corp. | |
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. | |
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | |
// | |
// Unless required by applicable law or agreed to in writing, | |
// software distributed under the License is distributed on an "AS IS" BASIS, | |
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
import UIKit | |
/** | |
Layout for a UIButton. | |
Since UIKit does not provide threadsafe methods to determine the size of a button given its content | |
it's implememtation hard-codes the current observed style of UIButton. | |
If the style of UIButton changes in the future, then the current implementation will need to be updated to reflect the new style. | |
If future-proofing is a concern for your application, then you should not use ButtonLayout and instead implement your own | |
custom layout that uses you own custom button view (e.g. by subclassing UIControl). | |
Similary, if you have your own custom button view, you will need to create your own custom layout for it. | |
*/ | |
open class ButtonLayout<Button: UIButton>: BaseLayout<Button>, ConfigurableLayout { | |
public let type: ButtonLayoutType | |
public let title: Text | |
public let image: ButtonLayoutImage | |
public let font: UIFont? | |
public let contentEdgeInsets: UIEdgeInsets | |
public init(type: ButtonLayoutType, | |
title: String, // TODO: support attributed text once we figure out how to get tests to pass | |
image: ButtonLayoutImage = .defaultImage, | |
font: UIFont? = nil, | |
contentEdgeInsets: UIEdgeInsets? = nil, | |
alignment: Alignment = ButtonLayoutDefaults.defaultAlignment, | |
flexibility: Flexibility = ButtonLayoutDefaults.defaultFlexibility, | |
viewReuseId: String? = nil, | |
config: ((Button) -> Void)? = nil) { | |
self.type = type | |
self.title = .unattributed(title) | |
self.image = image | |
self.font = font | |
self.contentEdgeInsets = contentEdgeInsets ?? ButtonLayout.defaultContentEdgeInsets(for: type, image: image) | |
super.init(alignment: alignment, flexibility: flexibility, viewReuseId: viewReuseId, config: config) | |
} | |
init(type: ButtonLayoutType, | |
title: String, | |
image: ButtonLayoutImage = .defaultImage, | |
font: UIFont? = nil, | |
contentEdgeInsets: UIEdgeInsets? = nil, | |
alignment: Alignment = ButtonLayoutDefaults.defaultAlignment, | |
flexibility: Flexibility = ButtonLayoutDefaults.defaultFlexibility, | |
viewReuseId: String? = nil, | |
viewClass: Button.Type? = nil, | |
config: ((UIButton) -> Void)? = nil) { | |
self.type = type | |
self.title = .unattributed(title) | |
self.image = image | |
self.font = font | |
self.contentEdgeInsets = contentEdgeInsets ?? ButtonLayout.defaultContentEdgeInsets(for: type, image: image) | |
super.init(alignment: alignment, flexibility: flexibility, viewReuseId: viewReuseId, viewClass: viewClass ?? Button.self, config: config) | |
} | |
private static func defaultContentEdgeInsets(for type: ButtonLayoutType, image: ButtonLayoutImage) -> UIEdgeInsets { | |
switch type { | |
case .custom, .detailDisclosure, .infoLight, .infoDark, .contactAdd: | |
return .zero | |
case .system: | |
#if os(tvOS) | |
if case .defaultImage = image { | |
return .zero | |
} | |
return UIEdgeInsets(top: 20, left: 40, bottom: 20, right: 40) | |
#else | |
return .zero | |
#endif | |
} | |
} | |
open func measurement(within maxSize: CGSize) -> LayoutMeasurement { | |
let imageSize = sizeOf(image: image) | |
let titleSize = sizeOfTitle(within: maxSize) | |
let width = max(minWidth, ceil(imageSize.width + titleSize.width + contentEdgeInsets.left + contentEdgeInsets.right)) | |
let height = ceil(max(imageSize.height, titleSize.height) + contentEdgeInsets.top + contentEdgeInsets.bottom + verticalPadding) | |
let size = CGSize(width: width, height: height).decreasedToSize(maxSize) | |
return LayoutMeasurement(layout: self, size: size, maxSize: maxSize, sublayouts: []) | |
} | |
/// Unlike UILabel, UIButton has nonzero height when the title is empty. | |
private func sizeOfTitle(within maxSize: CGSize) -> CGSize { | |
switch title { | |
case .attributed(let text): | |
if text.string.isEmpty && preserveHeightOfEmptyTitle { | |
let attributedText = NSMutableAttributedString(attributedString: text) | |
attributedText.mutableString.setString(" ") | |
return CGSize(width: 0, height: sizeOf(text: .attributed(attributedText), maxSize: maxSize).height) | |
} else { | |
return sizeOf(text: title, maxSize: maxSize) | |
} | |
case .unattributed(let text): | |
if text.isEmpty && preserveHeightOfEmptyTitle { | |
return CGSize(width: 0, height: sizeOf(text: .unattributed(" "), maxSize: maxSize).height) | |
} else { | |
return sizeOf(text: title, maxSize: maxSize) | |
} | |
} | |
} | |
private var preserveHeightOfEmptyTitle: Bool { | |
switch type { | |
case .custom, .system: | |
return true | |
case .detailDisclosure, .infoLight, .infoDark, .contactAdd: | |
return false | |
} | |
} | |
private func sizeOf(text: Text, maxSize: CGSize) -> CGSize { | |
return LabelLayout(text: text, font: fontForMeasurement, numberOfLines: 0).measurement(within: maxSize).size | |
} | |
/** | |
The font that should be used to measure the button's title. | |
This is based on observed behavior of UIButton. | |
*/ | |
private var fontForMeasurement: UIFont { | |
switch type { | |
case .custom: | |
return font ?? defaultFontForCustomButton | |
case .system: | |
return font ?? defaultFontForSystemButton | |
case .contactAdd, .infoLight, .infoDark, .detailDisclosure: | |
// Setting a custom font has no effect in this case. | |
return defaultFontForSystemButton | |
} | |
} | |
private var defaultFontForCustomButton: UIFont { | |
#if os(tvOS) | |
return UIFont.systemFont(ofSize: 38, weight: UIFont.Weight.medium) | |
#else | |
return UIFont.systemFont(ofSize: 18) | |
#endif | |
} | |
private var defaultFontForSystemButton: UIFont { | |
#if os(tvOS) | |
return UIFont.systemFont(ofSize: 38, weight: UIFont.Weight.medium) | |
#else | |
return UIFont.systemFont(ofSize: 15) | |
#endif | |
} | |
private func sizeOf(image: ButtonLayoutImage) -> CGSize { | |
switch image { | |
case .size(let size): | |
return size | |
case .image(let image): | |
return image?.size ?? .zero | |
case .defaultImage: | |
switch type { | |
case .custom, .system: | |
return .zero | |
case .contactAdd, .infoLight, .infoDark, .detailDisclosure: | |
#if os(tvOS) | |
return CGSize(width: 37, height: 46) | |
#else | |
return CGSize(width: 22, height: 22) | |
#endif | |
} | |
} | |
} | |
private var minWidth: CGFloat { | |
switch type { | |
case .custom, .system: | |
return hasCustomStyle ? 0 : 30 | |
case .detailDisclosure, .infoLight, .infoDark, .contactAdd: | |
return 0 | |
} | |
} | |
private var hasCustomStyle: Bool { | |
return hasCustomImage || contentEdgeInsets != .zero | |
} | |
private var hasCustomImage: Bool { | |
switch image { | |
case .size, .image: | |
return true | |
case .defaultImage: | |
return false | |
} | |
} | |
private var verticalPadding: CGFloat { | |
switch type { | |
case .custom, .system: | |
return hasCustomStyle ? 0 : 12 | |
case .detailDisclosure, .infoLight, .infoDark, .contactAdd: | |
#if os(tvOS) | |
return isTitleEmpty && !hasCustomImage ? -9 : 0 | |
#else | |
return 0 | |
#endif | |
} | |
} | |
private var isTitleEmpty: Bool { | |
switch title { | |
case .unattributed(let text): | |
return text.isEmpty | |
case .attributed(let text): | |
return text.string.isEmpty | |
} | |
} | |
open func arrangement(within rect: CGRect, measurement: LayoutMeasurement) -> LayoutArrangement { | |
let frame = alignment.position(size: measurement.size, in: rect) | |
return LayoutArrangement(layout: self, frame: frame, sublayouts: []) | |
} | |
open override func makeView() -> View { | |
return Button(type: type.buttonType) | |
} | |
open override func configure(view: Button) { | |
config?(view) | |
view.contentEdgeInsets = contentEdgeInsets | |
if let font = font { | |
view.titleLabel?.font = font | |
} | |
switch title { | |
case .unattributed(let text): | |
view.setTitle(text, for: .normal) | |
case .attributed(let text): | |
view.setAttributedTitle(text, for: .normal) | |
} | |
switch image { | |
case .image(let image): | |
view.setImage(image, for: .normal) | |
case .size, .defaultImage: | |
break | |
} | |
} | |
open override var needsView: Bool { | |
return true | |
} | |
} | |
/** | |
The image that appears on the button. | |
*/ | |
public enum ButtonLayoutImage { | |
/** | |
Use the default image for the button type. | |
(i.e. no image for .custom and .system, and the appropriate image for the other button types). | |
*/ | |
case defaultImage | |
/** | |
Specify the size of the image that will be set on the UIButton at a later point | |
(e.g. if you are loading the image from network). | |
*/ | |
case size(CGSize) | |
/** | |
The image to set on the button for UIControlState.normal. | |
You may configure the image for other states in the config block, | |
but they should be the same size as this image. | |
*/ | |
case image(UIImage?) | |
} | |
/** | |
Maps to UIButtonType. | |
This prevents LayoutKit from breaking if a new UIButtonType is added. | |
*/ | |
public enum ButtonLayoutType { | |
case custom | |
case system | |
case detailDisclosure | |
case infoLight | |
case infoDark | |
case contactAdd | |
public var buttonType: UIButton.ButtonType { | |
switch (self) { | |
case .custom: | |
return .custom | |
case .system: | |
return .system | |
case .detailDisclosure: | |
return .detailDisclosure | |
case .infoLight: | |
return .infoLight | |
case .infoDark: | |
return .infoDark | |
case .contactAdd: | |
return .contactAdd | |
} | |
} | |
} | |
public class ButtonLayoutDefaults { | |
public static let defaultAlignment = Alignment.topLeading | |
public static let defaultFlexibility = Flexibility.flexible | |
} | |