TM-SGNL-iOS/SignalUI/Views/BodyRanges/SpoilerRendering/SpoilerRenderer.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

296 lines
10 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import MetalKit
public import SignalServiceKit
/// Produces the actual particle effects used to tile spoilered regions.
public class SpoilerRenderer {
/// Configuration that applies to every particle within a given spoiler frame.
public struct Config: Hashable, Equatable {
// The first particle set will have this alpha
fileprivate let maxAlpha: CGFloat
// Subsequent particle sets will reduce their alpha by this much.
fileprivate let alphaDropoffRate: CGFloat
public let particleSizePixels: UInt8
fileprivate let color: ThemedColor
public static func standard(color: ThemedColor) -> Self {
return .init(
maxAlpha: 0.9,
alphaDropoffRate: 0.2,
particleSizePixels: UIScreen.main.scale > 2 ? 2 : 1,
color: color
)
}
public static func highlight(color: ThemedColor) -> Self {
return .init(
maxAlpha: 0.9,
alphaDropoffRate: 0.05,
particleSizePixels: 3,
color: color
)
}
// Values from 0 to 255.
var colorRGB: SIMD3<UInt8> {
var (r, g, b): (CGFloat, CGFloat, CGFloat) = (0, 0, 0)
color.forCurrentTheme.getRed(&r, green: &g, blue: &b, alpha: nil)
return .init(
UInt8(clamping: Int(r * 255)),
UInt8(clamping: Int(g * 255)),
UInt8(clamping: Int(b * 255))
)
}
// Value from 0 (transparent) to 255 (opaque).
var particleBaseAlpha: UInt8 {
return UInt8(clamping: Int(maxAlpha * 255))
}
// Value from 0 (transparent) to 255 (opaque).
var particleAlphaDropoff: UInt8 {
return UInt8(clamping: Int(alphaDropoffRate * 255))
}
}
/// A single frame into which to render spoilers.
public struct SpoilerFrame {
public var frame: CGRect
public var surfaceArea: SurfaceArea
public var config: Config
}
/// A set of spoiler frames within a larger boundingRect.
/// The boundingRect is expressed in the containing view's coordinates,
/// and is no larger than SpoilerMetalConfiguration.maxTextureDimensionPoints
/// on either dimension.
/// If a view is larger than a single spec can fit, multiple specs must
/// be provided to tile the entire view.
public struct Spec {
public var spoilerFrames: [SpoilerFrame]
public var totalSurfaceArea: SurfaceArea
public var boundingRect: CGRect
}
private let metalConfig: SpoilerMetalConfiguration
public init(metalConfig: SpoilerMetalConfiguration) {
self.metalConfig = metalConfig
NotificationCenter.default.addObserver(
self,
selector: #selector(didEnterForeground),
name: .OWSApplicationWillEnterForeground,
object: nil
)
NotificationCenter.default.addObserver(
self, selector:
#selector(didEnterBackground),
name: .OWSApplicationDidEnterBackground,
object: nil
)
NotificationCenter.default.addObserver(
self, selector:
#selector(reduceMotionSettingChanged),
name: UIAccessibility.reduceTransparencyStatusDidChangeNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Prepares a view for rendering spoilers, before actually having spoilers available
/// to render. (e.g. adds necessary subviews).
/// Only prepares a single tile at most.
public func prepareForRendering(onto view: UIView) {
if view.subviews.contains(where: { $0 is SpoilerParticleView }) {
return
}
let particleView = SpoilerParticleView(
metalConfig: metalConfig,
renderer: self
)
particleView.isInUse = false
view.addSubview(particleView)
}
public func render(_ specs: [Spec], onto view: UIView) {
// We pop from this array as we use the views.
var particleViews = view.subviews.compactMap { $0 as? SpoilerParticleView }
for spec in specs {
let particleView: SpoilerParticleView = {
if particleViews.isEmpty.negated {
let existing = particleViews.remove(at: 0)
if !existing.isInUse {
existing.isInUse = true
self.particleViews.append(Weak(value: existing))
}
return existing
} else {
let particleView = SpoilerParticleView(
metalConfig: metalConfig,
renderer: self
)
particleView.isInUse = true
self.particleViews.append(Weak(value: particleView))
view.addSubview(particleView)
return particleView
}
}()
particleView.isHidden = specs.isEmpty
particleView.frame = spec.boundingRect
particleView.spec = spec
particleView.commitChanges()
didChangeAnimationState()
}
// Remove any extra, unused views.
removeSpoilerViews(particleViews)
recomputeFidelity()
}
public func removeSpoilerViews(from view: UIView) {
removeSpoilerViews(view.subviews.lazy
.compactMap { $0 as? SpoilerParticleView }
)
recomputeFidelity()
}
private func recomputeFidelity() {
let totalSurfaceArea: SurfaceArea = self.particleViews.reduce(0, { sum, weakView in
guard
let spec = weakView.value?.spec,
weakView.value?.isInUse == true,
weakView.value?.isHidden == false
else {
return sum
}
return sum + spec.totalSurfaceArea
})
let fidelity = SpoilerParticleView.Fidelity.forTotalSurfaceArea(totalSurfaceArea)
self.particleViews.forEach {
$0.value?.fidelity = fidelity
}
}
private func removeSpoilerViews(_ spoilerViews: [SpoilerParticleView]) {
// We hide, instead of actually removing, so we can
// efficiently reuse them later (as often happens
// with table view cell reuse).
var removedViews = Set<SpoilerParticleView>()
spoilerViews
.forEach {
$0.isHidden = true
removedViews.insert($0)
}
// Cull from our tracked views.
if particleViews.isEmpty.negated {
let particleViewsCount = particleViews.count
for offsetFromEnd in 1...particleViews.count {
let index = particleViewsCount - offsetFromEnd
let weakView = particleViews[index]
guard let view = weakView.value else {
particleViews.remove(at: index).value?.isInUse = false
continue
}
if removedViews.contains(view) {
particleViews.remove(at: index).value?.isInUse = false
}
}
}
didChangeAnimationState()
}
private var particleViews = [Weak<SpoilerParticleView>]()
// MARK: - Time management
// We report the "duration" of the animation to our Metal shader on the
// hot path for rendering, so we need that to be as efficient as conceivably
// possible, down to using primitive types.
// The principle here is while we are animating, we keep the start time and
// subtract it from the current time. When we pause, we put the current duration
// into `extraAnimationDurationMs`, and remove the start time. When we resume again,
// we set a new start time and add the extra duration. This means if we animate
// for 5 seconds, pause for 2, and animate for 2 again, the "duration" will be
// 7 (2 duration + 5 "extra").
// If nil, we are not currently animating and therefore not tracking time changes.
private var animationStartMs: UInt32?
private var extraAnimationDurationMs: UInt32 = 0
// Reset duration every hour so numbers don't get too big.
private static var maxDurationMs: UInt32 = 60 * 60 * 1000
private func didChangeAnimationState() {
self.particleViews.removeAll(where: { $0.value == nil })
let wantsToAnimate =
isAppInForeground
&& !UIAccessibility.isReduceMotionEnabled
&& !self.particleViews.isEmpty
let wasAnimating = animationStartMs != nil
guard wantsToAnimate != wasAnimating else {
return
}
// Ok to drop higher order bits; we only care about duration
// measured in shorter timescales.
let currentDateMs = UInt32(truncatingIfNeeded: Date().ows_millisecondsSince1970)
if wantsToAnimate {
// resuming, set the current date (and preserve any extra)
animationStartMs = currentDateMs
} else {
// pausing, write the current duration to the extra.
extraAnimationDurationMs += currentDateMs - (animationStartMs ?? currentDateMs)
if extraAnimationDurationMs > Self.maxDurationMs {
extraAnimationDurationMs = 0
}
animationStartMs = nil
}
}
/// This method is on the hot path of rendering; should be as efficient as possible.
public func getAnimationDuration() -> UInt32 {
guard let animationStartMs else {
return extraAnimationDurationMs
}
// Ok to drop higher order bits; we only care about duration
// measured in shorter timescales.
let currentDateMs = UInt32(truncatingIfNeeded: Date().ows_millisecondsSince1970)
let duration = (currentDateMs - animationStartMs) + extraAnimationDurationMs
if duration > Self.maxDurationMs {
self.extraAnimationDurationMs = 0
self.animationStartMs = currentDateMs
return 0
} else {
return duration
}
}
// MARK: - Events
private var isAppInForeground = true { didSet { didChangeAnimationState() }}
@objc
private func didEnterForeground() {
isAppInForeground = true
}
@objc
private func didEnterBackground() {
isAppInForeground = false
}
@objc
private func reduceMotionSettingChanged() {
didChangeAnimationState()
}
}