Signal-iOS/Signal/ConversationView/CellViews/CVAttachmentProgressView.swift

440 lines
15 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
// A view for presenting attachment upload/download/failure/pending state.
class CVAttachmentProgressView: ManualLayoutView {
enum Direction {
case upload(attachmentStream: AttachmentStream)
case download(attachmentPointer: AttachmentPointer, downloadState: AttachmentDownloadState)
var attachmentId: Attachment.IDType {
switch self {
case .upload(let attachmentStream):
return attachmentStream.id
case .download(let attachmentPointer, _):
return attachmentPointer.id
}
}
}
struct Configuration {
enum BackgroundStyle {
case solidColor(UIColor)
case blur(UIBlurEffect)
}
let foregroundColor: UIColor
let backgroundStyle: BackgroundStyle
let margin: CGFloat
private init(foregroundColor: UIColor, backgroundStyle: BackgroundStyle, margin: CGFloat) {
self.foregroundColor = foregroundColor
self.backgroundStyle = backgroundStyle
self.margin = margin
}
init(conversationStyle: ConversationStyle, isIncoming: Bool, margin: CGFloat = 4) {
foregroundColor = conversationStyle.bubbleTextColor(isIncoming: isIncoming)
let backgroundColor = switch (conversationStyle.hasWallpaper, isIncoming) {
case (true, true): UIColor.Signal.MaterialBase.button
case (_, true): UIColor.Signal.LightBase.button
case (_, false): UIColor.Signal.ColorBase.button
}
backgroundStyle = .solidColor(backgroundColor)
self.margin = margin
}
/// Creates a configuration with fixed colors to be displayed on top of media thumbnail.
static func forMediaOverlay() -> Configuration {
return Configuration(
foregroundColor: .Signal.label,
backgroundStyle: .blur(.init(style: .systemThinMaterial)),
margin: 4,
)
}
}
enum State: Equatable {
case none
case tapToDownload
case unknownProgress
case progress(progress: Float)
var debugDescription: String {
switch self {
case .none:
"none"
case .tapToDownload:
"tapToDownload"
case .unknownProgress:
"unknownProgress"
case .progress(let progress):
"progress: \(progress)"
}
}
}
private let direction: Direction
private var _state: State = .none
var state: State {
get {
_state
}
set {
applyState(newValue, animated: false)
}
}
private var attachmentId: Attachment.IDType { direction.attachmentId }
init(
direction: Direction,
configuration: Configuration,
) {
self.direction = direction
super.init(name: "CVAttachmentProgressView")
tintColor = configuration.foregroundColor
layoutMargins = .init(margin: 4)
let backgroundView = Self.circularBackgroundView(configuration: configuration)
addSubviewToFillSuperviewEdges(backgroundView)
addSubviewToFillSuperviewMargins(contentView)
addLayoutBlock { view in
guard let view = view as? CVAttachmentProgressView else { return }
view.loadInitialStateIfNeeded()
}
}
// MARK: Placeholder View
/// - returns Pre-configured pill-shaped background for media download/upload progress indicator.
///
/// View returned is has a specific border and shadow and is also used outside of CVAttachmentProgressView
/// as a background for album media size label.
class func circularBackgroundView(configuration: Configuration) -> ManualLayoutView {
let view = ManualLayoutView(name: "backgroundView")
let circleView = ManualLayoutView.circleView(name: "circleView")
switch configuration.backgroundStyle {
case .solidColor(let backgroundColor):
circleView.backgroundColor = backgroundColor
case .blur(let blurEffect):
circleView.clipsToBounds = true
// Border and shadow.
// A separate layer must be used because `circleView` sets `clipsToBounds` to `true`
// and that is not compatible with an external shadow.
let borderAndShadowLayer = CAShapeLayer()
borderAndShadowLayer.fillColor = UIColor.clear.cgColor
borderAndShadowLayer.strokeColor = configuration.foregroundColor.withAlphaComponent(0.1).cgColor
borderAndShadowLayer.lineWidth = 1
borderAndShadowLayer.shadowColor = UIColor.black.cgColor
borderAndShadowLayer.shadowOpacity = Theme.isDarkThemeEnabled ? 0.32 : 0.12
borderAndShadowLayer.shadowRadius = 48
borderAndShadowLayer.shadowOffset = .zero
view.layer.addSublayer(borderAndShadowLayer)
view.addLayoutBlock { view in
guard let shapeLayer = view.layer.sublayers?.first(where: { $0 is CAShapeLayer }) as? CAShapeLayer else { return }
let cornerRadius = view.layer.bounds.size.smallerAxis / 2
let path = UIBezierPath(roundedRect: view.layer.bounds, cornerRadius: cornerRadius)
shapeLayer.frame = view.layer.bounds
shapeLayer.path = path.cgPath
shapeLayer.shadowPath = path.cgPath
}
let blurView = UIVisualEffectView(effect: blurEffect)
circleView.addSubviewToFillSuperviewEdges(blurView)
}
view.addSubviewToFillSuperviewEdges(circleView)
return view
}
// MARK: State
private let contentView = ManualLayoutViewWithLayer(name: "contentView")
private var progressView: CircularProgressView?
private var iconImageView: CVImageView?
// Set initial state and update UI accordingly without animations.
private func loadInitialStateIfNeeded() {
guard state == .none, window != nil, contentView.bounds.size.isNonEmpty else { return }
let animateStateChange = window != nil
switch direction {
case .upload:
applyState(.unknownProgress, animated: animateStateChange)
NotificationCenter.default.addObserver(
self,
selector: #selector(processUploadNotification(notification:)),
name: Upload.Constants.attachmentUploadProgressNotification,
object: nil,
)
case .download(_, let downloadState):
switch downloadState {
case .none, .failed:
applyState(.tapToDownload, animated: animateStateChange)
case .enqueuedOrDownloading:
applyState(.unknownProgress, animated: animateStateChange)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadProgressNotification,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadStoppedNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadStoppedNotification,
object: nil,
)
}
}
private func applyState(_ state: State, animated: Bool = false) {
let oldState = _state
guard state != oldState else { return }
_state = state
switch state {
case .none:
hideProgressView()
case .tapToDownload:
hideProgressView()
presentIcon(Theme.iconImage(.arrowDown))
case .progress(let progress):
switch oldState {
case .progress, .unknownProgress:
updateProgressView(progress: progress, animated: animated)
default:
presentProgressView(progress: progress, animated: animated)
if case .download = direction {
presentIcon(UIImage(named: "stop-20")!)
} else {
hideIcon()
}
}
case .unknownProgress:
presentIndeterminateProgressView(animated: animated)
if case .download = direction {
presentIcon(UIImage(named: "stop-20")!)
} else {
hideIcon()
}
}
}
private func presentIcon(_ image: UIImage) {
let imageView = ensureIconImageView()
imageView.image = image
}
private func hideIcon() {
iconImageView?.image = nil
}
private func ensureIconImageView() -> CVImageView {
if let iconImageView {
return iconImageView
}
let imageView = CVImageView(frame: contentView.bounds)
imageView.contentMode = .center
contentView.addSubviewToFillSuperviewEdges(imageView)
self.iconImageView = imageView
return imageView
}
private func presentIndeterminateProgressView(animated: Bool) {
let progressView = ensureProgressView()
guard animated else {
progressView.isHidden = false
progressView.startAnimating()
return
}
UIView.performWithoutAnimation {
progressView.isHidden = false
contentView.transform = .scale(0.8)
}
let animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 1, springResponse: 0.25)
animator.addAnimations {
self.contentView.transform = .identity
}
animator.addCompletion { [weak self] animationPosition in
guard let self, self.state == .unknownProgress else { return }
self.progressView?.startAnimating()
}
animator.startAnimation()
}
private func presentProgressView(progress: Float, animated: Bool) {
let progressView = ensureProgressView()
guard animated else {
progressView.isHidden = false
progressView.progress = progress
return
}
UIView.performWithoutAnimation {
progressView.isHidden = false
progressView.progress = progress
self.contentView.transform = .scale(0.8)
}
let animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 1, springResponse: 0.25)
animator.addAnimations {
self.contentView.transform = .identity
}
animator.startAnimation()
}
private func updateProgressView(progress: Float, animated: Bool) {
guard let progressView else {
owsFailDebug("Missing progressView.")
return
}
progressView.setProgress(progress, animated: animated)
}
// Create CircularProgressView, add it to view hierarchy and make it visible.
private func ensureProgressView() -> CircularProgressView {
if let progressView {
progressView.isHidden = false
return progressView
}
let progressView = CircularProgressView(frame: contentView.bounds)
contentView.addSubviewToFillSuperviewEdges(progressView)
self.progressView = progressView
return progressView
}
private func hideProgressView() {
progressView?.stopAnimating()
progressView?.isHidden = true
}
@objc
private func processDownloadNotification(notification: Notification) {
AssertIsOnMainThread()
guard
let attachmentId = notification.userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] as? Attachment.IDType
else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard attachmentId == self.attachmentId else {
return
}
guard let progress = notification.userInfo?[AttachmentDownloads.attachmentDownloadProgressKey] as? Float else {
owsFailDebug("No progress in attachment download progress notification.")
state = .unknownProgress
return
}
guard progress.isNaN == false, progress >= 0 else {
owsFailDebug("Invalid download progress value. [\(progress)]")
state = .unknownProgress
return
}
applyState(.progress(progress: progress), animated: window != nil)
}
@objc
private func processDownloadStoppedNotification(notification: Notification) {
AssertIsOnMainThread()
guard
let attachmentId = notification.userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] as? Attachment.IDType
else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard attachmentId == self.attachmentId else {
return
}
applyState(.tapToDownload, animated: window != nil)
}
@objc
private func processUploadNotification(notification: Notification) {
AssertIsOnMainThread()
guard let notificationAttachmentId = notification.userInfo?[Upload.Constants.uploadAttachmentIDKey] as? Attachment.IDType else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard notificationAttachmentId == attachmentId else {
return
}
guard let progress = notification.userInfo?[Upload.Constants.uploadProgressKey] as? Float else {
owsFailDebug("No progress in attachment upload progress notification.")
state = .unknownProgress
return
}
guard progress.isNaN == false, progress >= 0 else {
owsFailDebug("Invalid upload progress value. [\(progress)]")
state = .unknownProgress
return
}
applyState(.progress(progress: progress), animated: window != nil)
}
enum ProgressType {
case none
case uploading(attachmentStream: AttachmentStream)
case skipped(attachmentPointer: AttachmentPointer)
case downloading(attachmentPointer: AttachmentPointer, downloadState: AttachmentDownloadState)
}
static func progressType(cvAttachment: CVAttachment) -> ProgressType {
switch cvAttachment {
case .backupThumbnail:
// TODO: [Backups]: Update download state based on the media tier attachment state
return .none
case .stream(let referencedAttachmentStream, let isUploading, imageMetadata: _):
if isUploading {
return .uploading(attachmentStream: referencedAttachmentStream.attachmentStream)
} else {
return .none
}
case .pointer(let attachmentPointer, let downloadState):
switch downloadState {
case .none:
return .skipped(attachmentPointer: attachmentPointer.attachmentPointer)
case .failed, .enqueuedOrDownloading:
return .downloading(
attachmentPointer: attachmentPointer.attachmentPointer,
downloadState: downloadState,
)
}
case .undownloadable:
return .none
}
}
}