Signal-iOS/SignalUI/ActionSheets/HeroSheetViewController.swift
2026-05-20 12:16:01 -04:00

473 lines
16 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import Lottie
import SignalServiceKit
open class HeroSheetViewController: StackSheetViewController {
public enum Hero {
/// Scaled image to display at the top of the sheet
case image(UIImage, tintColor: UIColor? = nil)
/// Lottie name and height to display at the top of the sheet
case animation(named: String, height: CGFloat)
case circleIcon(
icon: UIImage,
iconSize: CGFloat,
tintColor: UIColor,
backgroundColor: UIColor,
)
}
public struct Body {
public enum TextContent {
case plain(String)
case attributed(NSAttributedString)
}
public struct BulletPoint {
public let icon: UIImage
public let text: String
public init(icon: UIImage, text: String) {
self.icon = icon
self.text = text
}
}
public struct Toggle {
public let text: String
public let isOn: Bool
public let onValueChanged: (_ isEnabled: Bool) -> Void
public init(
text: String,
isOn: Bool,
onValueChanged: @escaping (_ isEnabled: Bool) -> Void,
) {
self.text = text
self.isOn = isOn
self.onValueChanged = onValueChanged
}
}
public let textContent: TextContent
public let textAlignment: NSTextAlignment
public let textColor: UIColor
public let bulletPoints: [BulletPoint]
public let toggle: Toggle?
public init(
textContent: TextContent,
textAlignment: NSTextAlignment = .center,
textColor: UIColor = .Signal.secondaryLabel,
bulletPoints: [BulletPoint] = [],
toggle: Toggle? = nil,
) {
self.textContent = textContent
self.textAlignment = textAlignment
self.textColor = textColor
self.bulletPoints = bulletPoints
self.toggle = toggle
}
}
public enum Element {
case button(Button)
case hero(Hero)
}
public struct Button {
public enum Action {
case dismiss
case custom((HeroSheetViewController) -> Void)
}
public enum Style {
case primary
case secondary
case secondaryDestructive
}
fileprivate let title: String
fileprivate let action: Action
fileprivate let style: Style
public init(title: String, style: Style = .primary, action: Action) {
self.title = title
self.style = style
self.action = action
}
public init(title: String, action: @escaping (_: HeroSheetViewController) -> Void) {
self.init(title: title, action: .custom(action))
}
public static func dismissing(title: String, style: Style = .primary) -> Button {
Button(title: title, style: style, action: .dismiss)
}
fileprivate var configuration: UIButton.Configuration {
switch style {
case .primary:
return .largePrimary(title: title)
case .secondary:
return .largeSecondary(title: title)
case .secondaryDestructive:
var config: UIButton.Configuration = .largeSecondary(title: title)
config.baseForegroundColor = .Signal.red
return config
}
}
}
// MARK: -
private let hero: Hero
private let titleText: String?
private let body: Body
private let primary: Element?
private let secondary: Element?
public init(
hero: Hero,
title: String?,
body: String,
primaryButton: Button?,
secondaryButton: Button? = nil,
) {
self.hero = hero
self.titleText = title
self.body = Body(textContent: .plain(body))
self.primary = primaryButton.map { .button($0) }
self.secondary = secondaryButton.map { .button($0) }
super.init()
}
public init(
hero: Hero,
title: String?,
body: Body,
primary: Element?,
secondary: Element?,
) {
self.hero = hero
self.titleText = title
self.body = body
self.primary = primary
self.secondary = secondary
super.init()
}
// MARK: -
// .formSheet makes a blank sheet appear behind it
override public var modalPresentationStyle: UIModalPresentationStyle {
willSet {
if newValue == .formSheet {
owsFailDebug("Can't use formSheet for interactive sheets")
}
}
}
override public var stackViewInsets: UIEdgeInsets {
.init(top: 8, leading: 24, bottom: 32, trailing: 24)
}
override public func viewDidLoad() {
super.viewDidLoad()
let heroView = viewForHero(hero)
self.stackView.addArrangedSubview(heroView)
self.stackView.setCustomSpacing(16, after: heroView)
if let titleText {
let titleLabel = UILabel()
self.stackView.addArrangedSubview(titleLabel)
self.stackView.setCustomSpacing(12, after: titleLabel)
titleLabel.text = titleText
titleLabel.font = .dynamicTypeTitle2.bold()
titleLabel.numberOfLines = 0
titleLabel.textAlignment = .center
}
let bodyLabel = UILabel()
self.stackView.addArrangedSubview(bodyLabel)
self.stackView.setCustomSpacing(32, after: bodyLabel)
switch body.textContent {
case .plain(let text):
bodyLabel.text = text
bodyLabel.font = .dynamicTypeSubheadline
case .attributed(let attributedText):
// attributed strings should set their own font.
bodyLabel.attributedText = attributedText
}
bodyLabel.textColor = body.textColor
bodyLabel.textAlignment = body.textAlignment
bodyLabel.numberOfLines = 0
for bodyBullet in body.bulletPoints {
let bulletView = viewForBulletPoint(
bodyBullet,
textColor: body.textColor,
)
self.stackView.addArrangedSubview(bulletView)
self.stackView.setCustomSpacing(32, after: bulletView)
}
if let toggle = body.toggle {
let toggleView = viewForToggle(toggle)
self.stackView.addArrangedSubview(toggleView)
self.stackView.setCustomSpacing(32, after: toggleView)
}
if let primary {
let primaryButtonView = viewForElement(primary)
self.stackView.addArrangedSubview(primaryButtonView)
self.stackView.setCustomSpacing(20, after: primaryButtonView)
}
if let secondary {
let secondaryButtonView = viewForElement(secondary)
self.stackView.addArrangedSubview(secondaryButtonView)
}
}
private func viewForHero(_ hero: Hero) -> UIView {
let heroView: UIView
switch hero {
case let .image(image, tintColor):
heroView = UIImageView(image: image)
heroView.contentMode = .center
if let tintColor {
heroView.tintColor = tintColor
}
case let .animation(lottieName, height):
let lottieView = LottieAnimationView(name: lottieName)
lottieView.autoSetDimension(.height, toSize: height)
lottieView.contentMode = .scaleAspectFit
lottieView.loopMode = .loop
lottieView.play()
heroView = lottieView
case let .circleIcon(icon, iconSize, tintColor, backgroundColor):
let iconView = UIImageView(image: icon)
iconView.tintColor = tintColor
heroView = UIView()
let backgroundView = UIView()
heroView.addSubview(backgroundView)
backgroundView.autoPinHeightToSuperview()
backgroundView.autoHCenterInSuperview()
backgroundView.contentMode = .center
backgroundView.autoSetDimensions(to: .square(64))
backgroundView.layer.cornerRadius = 32
backgroundView.backgroundColor = backgroundColor
backgroundView.addSubview(iconView)
iconView.autoCenterInSuperview()
iconView.autoSetDimensions(to: .square(iconSize))
}
return heroView
}
private func viewForBulletPoint(
_ bulletPoint: Body.BulletPoint,
textColor: UIColor,
) -> UIView {
let bulletContainer = UIView()
bulletContainer.layoutMargins = UIEdgeInsets(hMargin: 24, vMargin: 0)
let iconImageView = UIImageView()
bulletContainer.addSubview(iconImageView)
iconImageView.image = bulletPoint.icon
iconImageView.tintColor = .Signal.secondaryLabel
let bulletLabel = UILabel()
bulletContainer.addSubview(bulletLabel)
bulletLabel.font = .dynamicTypeSubheadline
bulletLabel.textColor = textColor
bulletLabel.numberOfLines = 0
bulletLabel.textAlignment = .left
bulletLabel.text = bulletPoint.text
iconImageView.autoSetDimensions(to: .square(24))
iconImageView.autoPinEdge(toSuperviewMargin: .leading)
iconImageView.autoVCenterInSuperview()
iconImageView.autoPinEdge(.trailing, to: .leading, of: bulletLabel, withOffset: -12)
bulletLabel.autoPinEdges(toSuperviewMarginsExcludingEdge: .leading)
return bulletContainer
}
private func viewForToggle(_ toggle: Body.Toggle) -> UIView {
let label = UILabel()
label.text = toggle.text
label.font = .dynamicTypeSubheadline
label.textAlignment = .natural
label.numberOfLines = 0
label.textColor = .Signal.label
let toggleSwitch = UISwitch()
toggleSwitch.isOn = toggle.isOn
toggleSwitch.addAction(
UIAction { [weak self] action in
guard
let toggle = self?.body.toggle,
let toggleSwitch = action.sender as? UISwitch
else {
return
}
toggle.onValueChanged(toggleSwitch.isOn)
},
for: .valueChanged,
)
let containerView = PillView()
containerView.backgroundColor = .Signal.tertiaryBackground
containerView.layoutMargins = UIEdgeInsets(hMargin: 20, vMargin: 16)
containerView.addSubview(label)
containerView.addSubview(toggleSwitch)
label.autoPinEdges(toSuperviewMarginsExcludingEdge: .trailing)
toggleSwitch.autoPinEdge(.leading, to: .trailing, of: label, withOffset: 16, relation: .greaterThanOrEqual)
toggleSwitch.autoPinEdge(toSuperviewMargin: .trailing)
toggleSwitch.autoVCenterInSuperview()
return containerView
}
private func viewForElement(_ element: Element) -> UIView {
switch element {
case .button(let button):
let buttonView = self.buttonView(for: button)
buttonView.configuration = button.configuration
return buttonView
case .hero(let hero):
return viewForHero(hero)
}
}
private func buttonView(for button: Button) -> UIButton {
UIButton(
type: .system,
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
switch button.action {
case .dismiss:
self.dismiss(animated: true)
case .custom(let closure):
closure(self)
}
},
)
}
}
// MARK: -
#if DEBUG
@available(iOS 17, *)
#Preview("Image") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "linked-devices")!),
title: LocalizationNotNeeded("Finish linking on your other device"),
body: LocalizationNotNeeded("Finish linking Signal on your other device."),
primaryButton: .dismissing(title: CommonStrings.continueButton),
))
}
@available(iOS 17, *)
#Preview("Body w/ bullets") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "sustainer-heart")!),
title: nil,
body: HeroSheetViewController.Body(
textContent: .plain("As an independent nonprofit, Signal is committed to private messaging and calls. No ads, no trackers, no surveillance. Donate today to support Signal."),
textAlignment: .left,
textColor: .Signal.label,
bulletPoints: [
HeroSheetViewController.Body.BulletPoint(
icon: UIImage(named: "badge-multi")!,
text: "Get an optional badge on your profile when you donate",
),
HeroSheetViewController.Body.BulletPoint(
icon: UIImage(named: "lock")!,
text: "Your privacy is our mission",
),
HeroSheetViewController.Body.BulletPoint(
icon: UIImage(named: "heart")!,
text: "Signal is a 501c3 nonprofit. US donations are tax deductible.",
),
],
),
primary: nil,
secondary: nil,
))
}
@available(iOS 17, *)
#Preview("Body w/toggle") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "toggle-32")!),
title: nil,
body: HeroSheetViewController.Body(
textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#),
toggle: HeroSheetViewController.Body.Toggle(
text: "Extra Food?",
isOn: true,
onValueChanged: { enabled in
print(enabled ? "😸" : "😾")
},
),
),
primary: .button(.dismissing(title: "Order Up")),
secondary: nil,
))
}
@available(iOS 17, *)
#Preview("Animated") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .animation(named: "linking-device-light", height: 192),
title: LocalizationNotNeeded("Scan QR Code"),
body: LocalizationNotNeeded("Use this device to scan the QR code displayed on the device you want to link"),
primaryButton: .dismissing(title: CommonStrings.okayButton),
))
}
@available(iOS 17, *)
#Preview("Circle icon") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .circleIcon(
icon: UIImage(named: "key")!,
iconSize: 35,
tintColor: UIColor.Signal.label,
backgroundColor: UIColor.Signal.background,
),
title: LocalizationNotNeeded("No Recovery Key?"),
body: LocalizationNotNeeded("Backups cant be recovered without their 64-digit recovery code. If youve lost your recovery key Signal cant help restore your backup.\n\nIf you have your old device you can view your recovery key in Settings > Chats > Signal Backups. Then tap View recovery key."),
primaryButton: .dismissing(title: LocalizationNotNeeded("Skip & Dont Restore")),
secondaryButton: .dismissing(title: CommonStrings.learnMore),
))
}
@available(iOS 17, *)
#Preview("Footer animation") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "transfer_complete")!),
title: LocalizationNotNeeded("Continue on your other device"),
body: HeroSheetViewController.Body(textContent: .plain(LocalizationNotNeeded("Continue transferring your account on your other device."))),
primary: .hero(.animation(named: "circular_indeterminate", height: 60)),
secondary: nil,
))
}
#endif