Signal-iOS/SignalUI/ActionSheets/HeroSheetViewController.swift

544 lines
19 KiB
Swift
Raw Permalink 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 BonMot
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 title: String
public let footer: String?
public let isOn: Bool
public let onValueChanged: (_ isEnabled: Bool) -> Void
public init(
title: String,
footer: String?,
isOn: Bool,
onValueChanged: @escaping (_ isEnabled: Bool) -> Void,
) {
self.title = title
self.footer = footer
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
}
// Use a text view so embedded links in attributed bodies are tappable.
let bodyTextView = LinkingTextView()
self.stackView.addArrangedSubview(bodyTextView)
self.stackView.setCustomSpacing(32, after: bodyTextView)
switch body.textContent {
case .plain(let text):
bodyTextView.text = text
case .attributed(let attributedText):
bodyTextView.attributedText = attributedText
}
bodyTextView.font = .dynamicTypeSubheadline
bodyTextView.textColor = body.textColor
bodyTextView.textAlignment = body.textAlignment
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 titleLabel = UILabel()
titleLabel.text = toggle.title
titleLabel.font = .dynamicTypeSubheadline
titleLabel.textAlignment = .natural
titleLabel.numberOfLines = 0
titleLabel.textColor = .Signal.label
let toggleSwitch = UISwitch()
toggleSwitch.setCompressionResistanceHigh()
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 pillView = PillView()
pillView.backgroundColor = .Signal.tertiaryBackground
pillView.layoutMargins = UIEdgeInsets(hMargin: 20, vMargin: 16)
pillView.addSubview(titleLabel)
pillView.addSubview(toggleSwitch)
titleLabel.autoPinEdges(toSuperviewMarginsExcludingEdge: .trailing)
toggleSwitch.autoPinEdge(.leading, to: .trailing, of: titleLabel, withOffset: 16, relation: .greaterThanOrEqual)
toggleSwitch.autoPinEdge(toSuperviewMargin: .trailing)
toggleSwitch.autoVCenterInSuperview()
if let footer = toggle.footer {
let footerLabel = UILabel()
footerLabel.text = footer
footerLabel.font = .dynamicTypeFootnote
footerLabel.textAlignment = .natural
footerLabel.numberOfLines = 0
footerLabel.textColor = .Signal.secondaryLabel
let pillAndFooterContainer = UIView()
pillAndFooterContainer.addSubview(pillView)
pillAndFooterContainer.addSubview(footerLabel)
pillView.autoPinEdges(toSuperviewEdgesExcludingEdge: .bottom)
footerLabel.autoPinEdge(.top, to: .bottom, of: pillView, withOffset: 8)
footerLabel.autoPinEdgesToSuperviewEdges(
with: UIEdgeInsets(hMargin: 20, vMargin: 0),
excludingEdge: .top,
)
return pillAndFooterContainer
} else {
return pillView
}
}
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-and-footer") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "toggle-32")!),
title: "Feeding Boots the cat",
body: HeroSheetViewController.Body(
textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#),
toggle: HeroSheetViewController.Body.Toggle(
title: "Extra dinner?",
footer: "Side effects may include sleepiness and increased insistence that he receive extra food in the future.",
isOn: true,
onValueChanged: { enabled in
print(enabled ? "😸" : "😾")
},
),
),
primary: .button(.dismissing(title: "Order Up")),
secondary: nil,
))
}
@available(iOS 17, *)
#Preview("Body w/long-text toggle") {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "toggle-32")!),
title: "Feeding Boots the Cat",
body: HeroSheetViewController.Body(
textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#),
toggle: HeroSheetViewController.Body.Toggle(
title: "Give Boots extra dinner? Side effects may include sleepiness and increased insistence that he receive extra food in the future.",
footer: nil,
isOn: true,
onValueChanged: { enabled in
print(enabled ? "😸" : "😾")
},
),
),
primary: .button(.dismissing(title: "Order Up")),
secondary: nil,
))
}
@available(iOS 17, *)
#Preview("Body w/ link") {
let bodyText: NSAttributedString = NSAttributedString.composed(of: [
"Signal will never message you for your recovery key. Never respond to a chat pretending to be Signal. Never share your recovery key with anyone.",
" ",
CommonStrings.learnMore.styled(
with: .link(.Support.phishingPrevention),
),
])
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "avatar_football")!),
title: "Do Not Share Recovery Key",
body: HeroSheetViewController.Body(textContent: .attributed(bodyText)),
primary: .button(.dismissing(title: "Do Not Share Key")),
secondary: .button(HeroSheetViewController.Button(
title: LocalizationNotNeeded("Share Key"),
style: .secondaryDestructive,
action: .dismiss,
)),
))
}
@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