571 lines
20 KiB
Swift
571 lines
20 KiB
Swift
//
|
|
// 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 {
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
static func round(themeIcon: ThemeIcon) -> Self {
|
|
var configuration: UIButton.Configuration
|
|
if #available(iOS 26, *) {
|
|
configuration = .glass()
|
|
configuration.cornerStyle = .capsule
|
|
} else {
|
|
configuration = .plain()
|
|
}
|
|
configuration.image = Theme.iconImage(themeIcon)
|
|
configuration.baseForegroundColor = .Signal.label
|
|
configuration.contentInsets = .init(margin: 10) // 44 dp wide and tall if icon is a standard 24x24
|
|
return configuration
|
|
}
|
|
|
|
/// Round button with the flat gray background.
|
|
static func roundGray(image: UIImage) -> Self {
|
|
var configuration: UIButton.Configuration = .gray()
|
|
configuration.image = image
|
|
configuration.contentInsets = .init(margin: 10) // 44 dp wide and tall if icon is a standard 24x24
|
|
configuration.baseForegroundColor = .Signal.label
|
|
configuration.baseBackgroundColor = .Signal.tertiaryFill
|
|
configuration.cornerStyle = .capsule
|
|
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 "X" (Close) bar button which performs the action in the provided closure.
|
|
static func closeButton(action: @escaping () -> Void) -> UIBarButtonItem {
|
|
if #available(iOS 26, *) {
|
|
.systemItem(.close, action: action)
|
|
} else {
|
|
// This looks better without the circular background that system item has.
|
|
.button(icon: .buttonX, style: .plain, 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)
|
|
}
|
|
}
|
|
|
|
/// Creates ••• bar button that presents a popup menu with the provided actions.
|
|
static func contextMenuButton(actions: [UIAction]) -> UIBarButtonItem {
|
|
UIBarButtonItem(
|
|
image: Theme.iconImage(.buttonMore),
|
|
menu: UIMenu(children: actions),
|
|
)
|
|
}
|
|
|
|
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
|