// // 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