Path: blob/main/SignalUI/UIKitExtensions/UIButton+SignalUI.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
// MARK: - NSDirectionalEdgeInsets
private extension NSDirectionalEdgeInsets {
static var largeButtonContentInsets: NSDirectionalEdgeInsets {
NSDirectionalEdgeInsets(hMargin: 16, vMargin: 15)
}
static var mediumButtonContentInsets: NSDirectionalEdgeInsets {
NSDirectionalEdgeInsets(hMargin: 16, vMargin: 12)
}
static var smallButtonContentInsets: NSDirectionalEdgeInsets {
NSDirectionalEdgeInsets(hMargin: 12, vMargin: 8)
}
}
// MARK: - UIButton
public extension UIButton {
/// Add spacing between a button's image and its title.
///
/// Modified from [this project][0], licensed under the MIT License.
///
/// [0]: https://github.com/noahsark769/NGUIButtonInsetsExample
func setPaddingBetweenImageAndText(to padding: CGFloat, isRightToLeft: Bool) {
if isRightToLeft {
ows_contentEdgeInsets = .init(
top: ows_contentEdgeInsets.top,
left: padding,
bottom: ows_contentEdgeInsets.bottom,
right: ows_contentEdgeInsets.right,
)
ows_titleEdgeInsets = .init(
top: ows_titleEdgeInsets.top,
left: -padding,
bottom: ows_titleEdgeInsets.bottom,
right: padding,
)
} else {
ows_contentEdgeInsets = .init(
top: ows_contentEdgeInsets.top,
left: ows_contentEdgeInsets.left,
bottom: ows_contentEdgeInsets.bottom,
right: padding,
)
ows_titleEdgeInsets = .init(
top: ows_titleEdgeInsets.top,
left: padding,
bottom: ows_titleEdgeInsets.bottom,
right: -padding,
)
}
}
func setTemplateImage(_ templateImage: UIImage?, tintColor: UIColor) {
guard let templateImage else {
owsFailDebug("Missing image")
return
}
setImage(templateImage.withRenderingMode(.alwaysTemplate), for: .normal)
self.tintColor = tintColor
}
func setTemplateImageName(_ imageName: String, tintColor: UIColor) {
guard let image = UIImage(named: imageName) else {
owsFailDebug("Couldn't load image: \(imageName)")
return
}
setTemplateImage(image, tintColor: tintColor)
}
class func withTemplateImage(_ templateImage: UIImage?, tintColor: UIColor) -> UIButton {
let button = UIButton()
button.setTemplateImage(templateImage, tintColor: tintColor)
return button
}
class func withTemplateImageName(_ imageName: String, tintColor: UIColor) -> UIButton {
let button = UIButton()
button.setTemplateImageName(imageName, tintColor: tintColor)
return button
}
func setImage(_ image: UIImage?, animated: Bool) {
setImage(image, withAnimationDuration: animated ? 0.2 : 0)
}
func setImage(_ image: UIImage?, withAnimationDuration duration: TimeInterval) {
guard duration > 0 else {
setImage(image, for: .normal)
return
}
UIView.transition(with: self, duration: duration, options: .transitionCrossDissolve) {
self.setImage(image, for: .normal)
}
}
func enableMultilineLabel() {
guard let titleLabel else { return }
configuration?.titleAlignment = .center
configuration?.titleLineBreakMode = .byWordWrapping
titleLabel.numberOfLines = 0
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.textAlignment = .center
configurationUpdateHandler = { button in
button.titleLabel?.numberOfLines = 0
button.titleLabel?.lineBreakMode = .byWordWrapping
}
}
func enclosedInVerticalStackView(isFullWidthButton: Bool) -> UIStackView {
return [self].enclosedInVerticalStackView(isFullWidthButtons: isFullWidthButton)
}
}
public extension Array where Element == UIButton {
func enclosedInVerticalStackView(isFullWidthButtons: Bool) -> UIStackView {
return UIStackView.verticalButtonStack(buttons: self, isFullWidthButtons: isFullWidthButtons)
}
}
extension UIConfigurationTextAttributesTransformer {
/// Assign to a text attributes transformer (e.g., `UIButton.Configuration.titleTextAttributesTransformer`)
/// to configure a default font for that configuration.
///
/// This differs from setting the `AttributedText` directly in that a
/// `.font` attribute set directly on the attributed text will take
/// precedence over the default font.
public static func defaultFont(_ defaultFont: UIFont) -> UIConfigurationTextAttributesTransformer {
UIConfigurationTextAttributesTransformer { attributes in
guard attributes.font == nil else { return attributes }
var attributes = attributes
attributes.font = defaultFont
return attributes
}
}
}
public extension UIButton.Configuration {
private mutating func applyCorners() {
if #available(iOS 26, *) {
cornerStyle = .capsule
return
}
cornerStyle = .fixed
background.cornerRadius = 14
}
private static func basePrimary() -> Self {
var configuration: UIButton.Configuration
if #available(iOS 26, *) {
configuration = .prominentGlass()
} else {
configuration = .borderedProminent()
}
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
configuration.baseBackgroundColor = .Signal.accent
configuration.applyCorners()
return configuration
}
private static func baseSecondary() -> Self {
var configuration: UIButton.Configuration
if #available(iOS 26, *) {
configuration = .prominentGlass()
configuration.baseForegroundColor = .Signal.label
} else {
configuration = .plain()
configuration.baseForegroundColor = .Signal.accent
}
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
configuration.baseBackgroundColor = .clear
configuration.applyCorners()
return configuration
}
static func largePrimary(title: String) -> Self {
var configuration = basePrimary()
configuration.title = title
configuration.contentInsets = .largeButtonContentInsets
return configuration
}
static func largeSecondary(title: String) -> Self {
var configuration = baseSecondary()
configuration.title = title
configuration.contentInsets = .largeButtonContentInsets
if #unavailable(iOS 26) {
// Smaller height when button doesn't have visible shape looks better.
configuration.contentInsets.top = 8
configuration.contentInsets.bottom = 8
}
return configuration
}
static func mediumSecondary(title: String) -> Self {
var configuration = baseSecondary()
configuration.title = title
configuration.contentInsets = .mediumButtonContentInsets
if #unavailable(iOS 26) {
// Smaller height when button doesn't have visible shape looks better.
configuration.contentInsets.top = 8
configuration.contentInsets.bottom = 8
}
return configuration
}
static func mediumBorderless(title: String) -> Self {
var configuration = UIButton.Configuration.borderless()
configuration.title = title
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
configuration.contentInsets = .mediumButtonContentInsets
configuration.baseForegroundColor = .Signal.accent
configuration.baseBackgroundColor = .clear
return configuration
}
static func smallBorderless(title: String) -> Self {
var configuration = UIButton.Configuration.borderless()
configuration.title = title
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadlineClamped.semibold())
configuration.contentInsets = .smallButtonContentInsets
configuration.baseForegroundColor = .Signal.accent
configuration.baseBackgroundColor = .clear
return configuration
}
static func smallSecondary(title: String) -> Self {
var configuration = UIButton.Configuration.gray()
configuration.title = title
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadlineClamped.medium())
configuration.contentInsets = .smallButtonContentInsets
configuration.baseForegroundColor = .Signal.label
configuration.background.backgroundColor = .Signal.secondaryFill
return configuration
}
}
// MARK: - UIBarButtonItem
public extension UIBarButtonItem {
convenience init(
image: UIImage?,
style: UIBarButtonItem.Style,
target: Any?,
action: Selector?,
accessibilityIdentifier: String,
) {
self.init(image: image, style: style, target: target, action: action)
self.accessibilityIdentifier = accessibilityIdentifier
}
convenience init(
image: UIImage?,
landscapeImagePhone: UIImage?,
style: UIBarButtonItem.Style,
target: Any?,
action: Selector?,
accessibilityIdentifier: String,
) {
self.init(image: image, landscapeImagePhone: landscapeImagePhone, style: style, target: target, action: action)
self.accessibilityIdentifier = accessibilityIdentifier
}
convenience init(
title: String?,
style: UIBarButtonItem.Style,
target: Any?,
action: Selector?,
accessibilityIdentifier: String,
) {
self.init(title: title, style: style, target: target, action: action)
self.accessibilityIdentifier = accessibilityIdentifier
}
convenience init(
barButtonSystemItem systemItem: UIBarButtonItem.SystemItem,
target: Any?,
action: Selector?,
accessibilityIdentifier: String,
) {
self.init(barButtonSystemItem: systemItem, target: target, action: action)
self.accessibilityIdentifier = accessibilityIdentifier
}
convenience init(customView: UIView, accessibilityIdentifier: String) {
self.init(customView: customView)
self.accessibilityIdentifier = accessibilityIdentifier
}
private class ClosureBarButtonItem: UIBarButtonItem {
private class Handler {
var actionClosure: () -> Void
init(actionClosure: @escaping () -> Void) {
self.actionClosure = actionClosure
}
@objc
func action() {
actionClosure()
}
}
private var handler: Handler?
convenience init(
systemItem: UIBarButtonItem.SystemItem,
action: @escaping () -> Void,
) {
let handler = Handler(actionClosure: action)
// The `Handler` type exists because we can't
// reference `self` in its own initializer call.
self.init(barButtonSystemItem: systemItem, target: handler, action: #selector(handler.action))
// Keep a strong reference to the Handler
self.handler = handler
}
convenience init(
title: String,
style: UIBarButtonItem.Style,
action: @escaping () -> Void,
) {
let handler = Handler(actionClosure: action)
self.init(title: title, style: style, target: handler, action: #selector(handler.action))
self.handler = handler
}
convenience init(
image: UIImage,
style: UIBarButtonItem.Style,
action: @escaping () -> Void,
) {
let handler = Handler(actionClosure: action)
self.init(image: image, style: style, target: handler, action: #selector(handler.action))
self.handler = handler
}
}
/// Creates a bar button with the given title that performs the action in the provided closure.
static func button(
title: String,
style: UIBarButtonItem.Style,
action: @escaping () -> Void,
) -> UIBarButtonItem {
ClosureBarButtonItem(title: title, style: style, action: action)
}
/// Creates a bar button with the given icon that performs the action in the provided closure.
static func button(
icon: ThemeIcon,
style: UIBarButtonItem.Style,
action: @escaping () -> Void,
) -> UIBarButtonItem {
ClosureBarButtonItem(image: Theme.iconImage(icon), style: style, action: action)
}
/// Creates a bar button with the given image that performs the action in the provided closure.
static func button(
image: UIImage,
style: UIBarButtonItem.Style,
action: @escaping () -> Void,
) -> UIBarButtonItem {
ClosureBarButtonItem(image: image, style: style, action: action)
}
// Keep this static function public instead of exposing ClosureBarButtonItem
// because ClosureBarButtonItem will only function properly if using its
// custom convenience initializer.
/// Creates a system bar button item which performs the action in the provided closure.
///
/// - Parameters:
/// - systemItem: The system item to use.
/// - action: The action to perform on tap.
/// - Returns: A new `UIBarButtonItem`.
static func systemItem(
_ systemItem: UIBarButtonItem.SystemItem,
action: @escaping () -> Void,
) -> UIBarButtonItem {
ClosureBarButtonItem(systemItem: systemItem, action: action)
}
/// Creates a "Cancel" bar button which performs the action in the provided closure.
static func cancelButton(action: @escaping () -> Void) -> UIBarButtonItem {
Self.systemItem(.cancel, action: action)
}
/// Creates a "Cancel" bar button which dismisses the view using the provided view controller.
/// - Parameters:
/// - viewController: The view controller to dismiss from.
/// - animated: Whether to animate the dismiss.
/// - completion: The block to execute after the view controller is dismissed.
/// - Returns: A new `UIBarButtonItem`.
static func cancelButton(
dismissingFrom viewController: UIViewController?,
animated: Bool = true,
completion: (() -> Void)? = nil,
) -> UIBarButtonItem {
Self.cancelButton { [weak viewController] in
viewController?.dismiss(animated: animated, completion: completion)
}
}
/// Creates a "Cancel" bar button which dismisses the view after checking if
/// there are unsaved changes and presenting a confirmation sheet if so.
/// - Parameters:
/// - viewController: The view controller to display the confirmation and to dismiss from.
/// - hasUnsavedChanges: A closure called on tap to check if there are
/// unsaved changes. Returning `nil` is equivalent to returning `false`.
/// - animated: Whether to animate the dismiss.
/// - completion: The block to execute after the view controller is dismissed.
/// - Returns: A new `UIBarButtonItem`.
static func cancelButton(
dismissingFrom viewController: UIViewController?,
hasUnsavedChanges: @escaping () -> Bool?,
animated: Bool = true,
completion: (() -> Void)? = nil,
) -> UIBarButtonItem {
Self.cancelButton { [weak viewController] in
if hasUnsavedChanges() == true {
OWSActionSheets.showPendingChangesActionSheet(discardAction: { [weak viewController] in
viewController?.dismiss(animated: animated, completion: completion)
})
} else {
viewController?.dismiss(animated: animated, completion: completion)
}
}
}
/// Creates a "Cancel" bar button which pops the view controller using the provided navigation controller.
/// - Parameters:
/// - navigationController: The navigation controller to pop.
/// - animated: Whether to animate the pop.
/// - Returns: A new `UIBarButtonItem`.
static func cancelButton(
poppingFrom navigationController: UINavigationController?,
animated: Bool = true,
) -> UIBarButtonItem {
Self.cancelButton { [weak navigationController] in
navigationController?.popViewController(animated: animated)
}
}
/// Creates a "Done" bar button which performs the action in the provided closure.
static func doneButton(action: @escaping () -> Void) -> UIBarButtonItem {
Self.systemItem(.done, action: action)
}
/// Creates a "Done" bar button which dismisses the view using the provided view controller.
/// - Parameters:
/// - viewController: The view controller to dismiss from.
/// - animated: Whether to animate the dismiss.
/// - completion: The block to execute after the view controller is dismissed.
/// - Returns: A new `UIBarButtonItem`.
static func doneButton(
dismissingFrom viewController: UIViewController?,
animated: Bool = true,
completion: (() -> Void)? = nil,
) -> UIBarButtonItem {
let systemItem: SystemItem = if #available(iOS 26, *) {
.close
} else {
.done
}
return Self.systemItem(systemItem) { [weak viewController] in
viewController?.dismiss(animated: animated, completion: completion)
}
}
static func setButton(action: @escaping () -> Void) -> UIBarButtonItem {
if #available(iOS 26, *) {
// iOS 26 done buttons appear as a big blue checkmark
return .systemItem(.done, action: action)
} else {
// For iOS 18 and older, we want to use the text "Set"
return .button(
title: CommonStrings.setButton,
style: .done,
action: action,
)
}
}
// Feel free to add more system item functions as the need arises
}
// MARK: - UIToolbar
public extension UIToolbar {
static func clear() -> UIToolbar {
let toolbar = UIToolbar()
toolbar.backgroundColor = .clear
// Making a toolbar transparent requires setting an empty uiimage
toolbar.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: .default)
// hide 1px top-border
toolbar.clipsToBounds = true
return toolbar
}
}
// MARK: -
#if DEBUG
private class ButtonPreviewViewController: UIViewController {
private let buttonConfiguration: UIButton.Configuration
init(_ buttonConfiguration: UIButton.Configuration) {
self.buttonConfiguration = buttonConfiguration
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("") }
override func viewDidLoad() {
let button = UIButton(configuration: buttonConfiguration)
view.addSubview(button)
button.autoCenterInSuperviewMargins()
}
}
@available(iOS 17, *)
#Preview("Large Primary") {
return ButtonPreviewViewController(.largePrimary(title: "Large Primary"))
}
@available(iOS 17, *)
#Preview("Large Secondary") {
return ButtonPreviewViewController(.largeSecondary(title: "Large Secondary"))
}
@available(iOS 17, *)
#Preview("Medium Secondary") {
return ButtonPreviewViewController(.mediumSecondary(title: "Medium Secondary"))
}
@available(iOS 17, *)
#Preview("Medium Borderless") {
return ButtonPreviewViewController(.mediumBorderless(title: "Medium Borderless"))
}
@available(iOS 17, *)
#Preview("Small Secondary") {
return ButtonPreviewViewController(.smallSecondary(title: "Small Secondary"))
}
@available(iOS 17, *)
#Preview("Small Borderless") {
return ButtonPreviewViewController(.smallBorderless(title: "Small Borderless"))
}
#endif