Path: blob/main/SignalUI/ActionSheets/HeroSheetViewController.swift
1 views
//
// 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 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 text: String
public let textAlignment: NSTextAlignment
public let textColor: UIColor
public let bulletPoints: [BulletPoint]
public let toggle: Toggle?
public init(
text: String,
textAlignment: NSTextAlignment = .center,
textColor: UIColor = .Signal.secondaryLabel,
bulletPoints: [BulletPoint] = [],
toggle: Toggle? = nil,
) {
self.text = text
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(text: 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)
bodyLabel.text = body.text
bodyLabel.textColor = body.textColor
bodyLabel.textAlignment = body.textAlignment
bodyLabel.font = .dynamicTypeSubheadline
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(
text: "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(
text: #"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 can’t be recovered without their 64-digit recovery code. If you’ve lost your recovery key Signal can’t 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 & Don’t 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(text: LocalizationNotNeeded("Continue transferring your account on your other device.")),
primary: .hero(.animation(named: "circular_indeterminate", height: 60)),
secondary: nil,
))
}
#endif