TM-SGNL-iOS/Signal/Megaphones/UserInterface/MegaphoneView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

345 lines
11 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
import SignalServiceKit
import SignalUI
class MegaphoneView: UIView, ExperienceUpgradeView {
let experienceUpgrade: ExperienceUpgrade
var imageName: String? {
didSet {
if imageName != nil { image = nil }
}
}
var image: UIImage? {
didSet {
if image != nil { imageName = nil }
}
}
var imageContentMode: UIView.ContentMode = .scaleAspectFit
var animation: Animation?
struct Animation {
let name: String
let backgroundImageName: String?
let backgroundImageInset: CGFloat
let speed: CGFloat
let loopMode: LottieLoopMode
let backgroundBehavior: LottieBackgroundBehavior
let contentMode: UIView.ContentMode
init(
name: String,
backgroundImageName: String? = nil,
backgroundImageInset: CGFloat = 0,
speed: CGFloat = 1,
loopMode: LottieLoopMode = .playOnce,
backgroundBehavior: LottieBackgroundBehavior = .forceFinish,
contentMode: UIView.ContentMode = .scaleAspectFit
) {
self.name = name
self.speed = speed
self.loopMode = loopMode
self.backgroundBehavior = backgroundBehavior
self.contentMode = contentMode
self.backgroundImageName = backgroundImageName
self.backgroundImageInset = backgroundImageInset
}
}
enum ButtonOrientation {
case horizontal, vertical
}
var buttonOrientation: ButtonOrientation = .horizontal {
willSet { assert(!hasPresented) }
}
var titleText: String? {
willSet { assert(!hasPresented) }
}
var bodyText: String? {
willSet { assert(!hasPresented) }
}
struct Button {
let title: String
let action: () -> Void
}
private var buttons: [Button] = []
func setButtons(primary: Button, secondary: Button? = nil) {
assert(!hasPresented)
if let secondary = secondary {
buttons = [primary, secondary]
} else {
buttons = [primary]
}
}
var isPresented: Bool { superview != nil }
private let darkThemeBackgroundOverlay = UIView()
private let stackView = UIStackView()
init(experienceUpgrade: ExperienceUpgrade) {
self.experienceUpgrade = experienceUpgrade
super.init(frame: .zero)
layer.cornerRadius = 12
clipsToBounds = true
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
addSubview(blurEffectView)
blurEffectView.autoPinEdgesToSuperviewEdges()
addSubview(darkThemeBackgroundOverlay)
darkThemeBackgroundOverlay.autoPinEdgesToSuperviewEdges()
darkThemeBackgroundOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.10)
stackView.axis = .vertical
addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .themeDidChange, object: nil)
applyTheme()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var hasPresented = false
func present(fromViewController: UIViewController) {
AssertIsOnMainThread()
guard !hasPresented else { return owsFailDebug("can only present once") }
guard titleText != nil, bodyText != nil else {
return owsFailDebug("megaphone is not prepared for presentation")
}
// Top section
let labelStack = createLabelStack()
let topStackSubviews: [UIView]
if imageName != nil || image != nil || animation != nil {
topStackSubviews = [createImageContainer(), labelStack]
} else {
topStackSubviews = [labelStack]
}
let topStackView = UIStackView(arrangedSubviews: topStackSubviews)
topStackView.axis = .horizontal
topStackView.spacing = 8
topStackView.isLayoutMarginsRelativeArrangement = true
topStackView.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)
stackView.addArrangedSubview(topStackView)
// Buttons
if buttons.count > 0 {
stackView.addArrangedSubview(createButtonsStack())
} else {
assert(buttons.isEmpty)
addDismissButton()
}
fromViewController.view.addSubview(self)
autoPinEdge(toSuperviewSafeArea: .leading, withInset: 8)
autoPinEdge(toSuperviewSafeArea: .trailing, withInset: 8)
autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 8)
animationView?.play()
alpha = 0
UIView.animate(withDuration: 0.2) {
self.alpha = 1
}
hasPresented = true
}
@objc
private func applyTheme() {
darkThemeBackgroundOverlay.isHidden = !Theme.isDarkThemeEnabled
}
@objc
private func tappedDismiss() {
dismiss()
}
func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) {
UIView.animate(withDuration: animated ? 0.2 : 0, animations: {
self.alpha = 0
}) { _ in
self.removeFromSuperview()
completion?()
}
}
func createLabelStack() -> UIStackView {
let titleLabel = UILabel()
titleLabel.numberOfLines = 0
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.font = UIFont.semiboldFont(ofSize: 17)
titleLabel.textColor = Theme.darkThemePrimaryColor
titleLabel.text = titleText
let bodyLabel = UILabel()
bodyLabel.numberOfLines = 0
bodyLabel.lineBreakMode = .byWordWrapping
bodyLabel.font = UIFont.systemFont(ofSize: 15)
bodyLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor
bodyLabel.text = bodyText
let topSpacer = UIView()
let bottomSpacer = UIView()
let labelStack = UIStackView(arrangedSubviews: [topSpacer, titleLabel, bodyLabel, bottomSpacer])
labelStack.axis = .vertical
topSpacer.autoMatch(.height, to: .height, of: bottomSpacer)
return labelStack
}
private var animationView: LottieAnimationView?
func createImageContainer() -> UIView {
let container: UIView
if let image = { () -> UIImage? in
if let imageName = imageName { return UIImage(named: imageName) }
return image
}() {
container = UIView()
let imageView = UIImageView()
imageView.image = image
imageView.contentMode = self.imageContentMode
container.addSubview(imageView)
imageView.autoPinWidthToSuperview()
imageView.autoPinToSquareAspectRatio()
imageView.autoVCenterInSuperview()
} else if let animation = animation {
container = UIView()
if let backgroundImageName = animation.backgroundImageName {
let backgroundImageView = UIImageView()
backgroundImageView.image = UIImage(named: backgroundImageName)
backgroundImageView.contentMode = .scaleAspectFill
container.addSubview(backgroundImageView)
backgroundImageView.autoPinWidthToSuperview(withMargin: animation.backgroundImageInset)
backgroundImageView.autoVCenterInSuperview()
}
let animationView = LottieAnimationView(name: animation.name)
self.animationView = animationView
animationView.contentMode = animation.contentMode
animationView.animationSpeed = animation.speed
animationView.loopMode = animation.loopMode
animationView.backgroundBehavior = animation.backgroundBehavior
container.addSubview(animationView)
animationView.autoPinEdgesToSuperviewEdges()
} else {
owsFailDebug("unexpectedly missing animation and image")
container = UIView()
}
container.autoSetDimension(.width, toSize: 64)
container.autoSetDimension(.height, toSize: 64, relation: .greaterThanOrEqual)
return container
}
func createButtonView(_ button: Button, font: UIFont = .regularFont(ofSize: 15)) -> OWSFlatButton {
let buttonView = OWSFlatButton()
buttonView.setTitle(title: button.title, font: font, titleColor: Theme.darkThemePrimaryColor)
buttonView.setPressedBlock { button.action() }
buttonView.autoSetDimension(.height, toSize: 44)
return buttonView
}
func createButtonsStack() -> UIStackView {
let buttonsStack = UIStackView()
buttonsStack.addBackgroundView(withBackgroundColor: .ows_blackAlpha20)
switch buttons.count {
case 1:
buttonsStack.addArrangedSubview(createButtonView(buttons[0]))
case 2:
var previousButton: UIView?
for button in buttons {
let buttonView = createButtonView(
button,
font: previousButton == nil ? UIFont.semiboldFont(ofSize: 15) : .regularFont(ofSize: 15)
)
switch buttonOrientation {
case .vertical:
buttonsStack.addArrangedSubview(buttonView)
case .horizontal:
buttonsStack.insertArrangedSubview(buttonView, at: 0)
}
previousButton?.autoMatch(.width, to: .width, of: buttonView)
previousButton = buttonView
}
let dividerContainer = UIView()
let divider = UIView()
divider.backgroundColor = .ows_whiteAlpha20
dividerContainer.addSubview(divider)
buttonsStack.insertArrangedSubview(dividerContainer, at: 1)
switch buttonOrientation {
case .vertical:
buttonsStack.axis = .vertical
divider.autoSetDimension(.height, toSize: 1)
divider.autoPinHeightToSuperview()
divider.autoPinWidthToSuperview(withMargin: 12)
case .horizontal:
buttonsStack.axis = .horizontal
divider.autoSetDimension(.width, toSize: 1)
divider.autoPinWidthToSuperview()
divider.autoPinHeightToSuperview(withMargin: 8)
}
default:
owsFailDebug("only supports 1 or 2 buttons")
}
return buttonsStack
}
func addDismissButton() {
let dismissButton = UIButton()
dismissButton.setTemplateImage(Theme.iconImage(.buttonX), tintColor: Theme.darkThemePrimaryColor)
dismissButton.addTarget(self, action: #selector(tappedDismiss), for: .touchUpInside)
addSubview(dismissButton)
dismissButton.autoSetDimensions(to: CGSize(square: 40))
dismissButton.autoPinEdge(toSuperviewEdge: .trailing)
dismissButton.autoPinEdge(toSuperviewEdge: .top)
}
func snoozeButton(fromViewController: UIViewController, snoozeTitle: String = MegaphoneStrings.remindMeLater) -> Button {
return Button(title: snoozeTitle) { [weak self] in
self?.markAsSnoozedWithSneakyTransaction()
self?.dismiss {
self?.presentToast(text: MegaphoneStrings.weWillRemindYouLater, fromViewController: fromViewController)
}
}
}
}