291 lines
10 KiB
Swift
291 lines
10 KiB
Swift
//
|
||
// Copyright 2024 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
import Foundation
|
||
import SignalServiceKit
|
||
import Lottie
|
||
|
||
open class HeroSheetViewController: StackSheetViewController {
|
||
public enum Hero {
|
||
/// Scaled image to display at the top of the sheet
|
||
case image(UIImage)
|
||
/// 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 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
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
public var configuration: UIButton.Configuration {
|
||
switch style {
|
||
case .primary:
|
||
var buttonConfiguration = UIButton.Configuration.filled()
|
||
var buttonTitleAttributes = AttributeContainer()
|
||
buttonTitleAttributes.font = .dynamicTypeHeadline
|
||
buttonTitleAttributes.foregroundColor = .white
|
||
buttonConfiguration.attributedTitle = AttributedString(
|
||
title,
|
||
attributes: buttonTitleAttributes
|
||
)
|
||
buttonConfiguration.contentInsets = .init(hMargin: 16, vMargin: 14)
|
||
buttonConfiguration.background.cornerRadius = 10
|
||
buttonConfiguration.background.backgroundColor = UIColor.Signal.ultramarine
|
||
return buttonConfiguration
|
||
case .secondary:
|
||
var buttonConfiguration = UIButton.Configuration.plain()
|
||
var buttonTitleAttributes = AttributeContainer()
|
||
buttonTitleAttributes.font = .dynamicTypeHeadline
|
||
buttonTitleAttributes.foregroundColor = UIColor.Signal.ultramarine
|
||
buttonConfiguration.attributedTitle = AttributedString(
|
||
title,
|
||
attributes: buttonTitleAttributes
|
||
)
|
||
return buttonConfiguration
|
||
}
|
||
}
|
||
}
|
||
|
||
private let hero: Hero
|
||
private let titleText: String
|
||
private let bodyText: String
|
||
private let primary: Element
|
||
private let secondary: Element?
|
||
|
||
/// Creates a hero image sheet with a CTA button.
|
||
/// - Parameters:
|
||
/// - hero: The main content to display at the top of the sheet
|
||
/// - title: Localized title text
|
||
/// - body: Localized body text
|
||
/// - primaryButton: The title and action for the CTA button
|
||
/// - secondaryButton: The title and action for an optional secondary button
|
||
/// If `nil`, the button will dismiss the sheet.
|
||
public convenience init(
|
||
hero: Hero,
|
||
title: String,
|
||
body: String,
|
||
primaryButton: Button,
|
||
secondaryButton: Button? = nil
|
||
) {
|
||
let secondaryCTA: Element? = {
|
||
guard let secondaryButton else { return nil }
|
||
return .button(secondaryButton)
|
||
}()
|
||
|
||
self.init(
|
||
hero: hero,
|
||
title: title,
|
||
body: body,
|
||
primary: .button(primaryButton),
|
||
secondary: secondaryCTA
|
||
)
|
||
}
|
||
|
||
public init(
|
||
hero: Hero,
|
||
title: String,
|
||
body: String,
|
||
primary: Element,
|
||
secondary: Element? = nil
|
||
) {
|
||
self.hero = hero
|
||
self.titleText = title
|
||
self.bodyText = body
|
||
self.primary = primary
|
||
self.secondary = secondary
|
||
super.init()
|
||
}
|
||
|
||
// .formSheet makes a blank sheet appear behind it
|
||
public override var modalPresentationStyle: UIModalPresentationStyle {
|
||
willSet {
|
||
if newValue == .formSheet {
|
||
owsFailDebug("Can't use formSheet for interactive sheets")
|
||
}
|
||
}
|
||
}
|
||
|
||
public override var stackViewInsets: UIEdgeInsets {
|
||
.init(top: 8, leading: 24, bottom: 32, trailing: 24)
|
||
}
|
||
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
|
||
let heroView = viewForHero(hero)
|
||
self.stackView.addArrangedSubview(heroView)
|
||
self.stackView.setCustomSpacing(16, after: heroView)
|
||
|
||
let titleLabel = UILabel()
|
||
self.stackView.addArrangedSubview(titleLabel)
|
||
self.stackView.setCustomSpacing(12, after: titleLabel)
|
||
titleLabel.text = self.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 = self.bodyText
|
||
bodyLabel.font = .dynamicTypeSubheadline
|
||
bodyLabel.textColor = UIColor.Signal.secondaryLabel
|
||
bodyLabel.numberOfLines = 0
|
||
bodyLabel.textAlignment = .center
|
||
|
||
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):
|
||
heroView = UIImageView(image: image)
|
||
heroView.contentMode = .center
|
||
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 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)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
#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("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: LocalizationNotNeeded("Continue transferring your account on your other device."),
|
||
primary: .hero(.animation(named: "circular_indeterminate", height: 60))
|
||
))
|
||
}
|
||
#endif
|