Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,609 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import AnimationCache
import MultiAnimationRenderer
import TelegramCore
import AccountContext
import SwiftSignalKit
import EmojiTextAttachmentView
import LokiRng
private final class PatternContentsTarget: MultiAnimationRenderTarget {
private let imageUpdated: (Bool) -> Void
init(imageUpdated: @escaping (Bool) -> Void) {
self.imageUpdated = imageUpdated
super.init()
}
required init(coder: NSCoder) {
preconditionFailure()
}
override func transitionToContents(_ contents: AnyObject, didLoop: Bool) {
let hadContents = self.contents != nil
self.contents = contents
self.imageUpdated(hadContents)
}
}
private func windowFunction(t: CGFloat) -> CGFloat {
return bezierPoint(0.6, 0.0, 0.4, 1.0, t)
}
private func patternScaleValueAt(fraction: CGFloat, t: CGFloat, reverse: Bool) -> CGFloat {
let windowSize: CGFloat = 0.8
let effectiveT: CGFloat
let windowStartOffset: CGFloat
let windowEndOffset: CGFloat
if reverse {
effectiveT = 1.0 - t
windowStartOffset = 1.0
windowEndOffset = -windowSize
} else {
effectiveT = t
windowStartOffset = -windowSize
windowEndOffset = 1.0
}
let windowPosition = (1.0 - fraction) * windowStartOffset + fraction * windowEndOffset
let windowT = max(0.0, min(windowSize, effectiveT - windowPosition)) / windowSize
let localT = 1.0 - windowFunction(t: windowT)
return localT
}
public final class PeerInfoCoverComponent: Component {
public enum Subject: Equatable {
case peer(EnginePeer)
case status(PeerEmojiStatus)
case custom(UIColor?, UIColor?, UIColor?, Int64?)
func colors(context: AccountContext, isDark: Bool) -> (UIColor, UIColor)? {
switch self {
case let .peer(peer):
if let colors = peer._asPeer().profileColor.flatMap({ context.peerNameColors.getProfile($0, dark: isDark) }) {
let backgroundColor = colors.main
let secondaryBackgroundColor = colors.secondary ?? colors.main
return (backgroundColor, secondaryBackgroundColor)
} else {
return nil
}
case let .status(status):
if case let .starGift(_, _, _, _, _, innerColor, outerColor, _, _) = status.content {
return (UIColor(rgb: UInt32(bitPattern: innerColor)), UIColor(rgb: UInt32(bitPattern: outerColor)))
}
return nil
case let .custom(color, secondColor, _, _):
if let color {
if let secondColor {
return (color, secondColor)
} else {
return (color, color)
}
} else {
return nil
}
}
}
var fileId: Int64? {
switch self {
case let .peer(peer):
return peer.profileBackgroundEmojiId
case let .status(status):
if case let .starGift(_, _, _, _, patternFileId, _, _, _, _) = status.content {
return patternFileId
}
return nil
case let .custom(_, _, _, fileId):
return fileId
}
}
}
public let context: AccountContext
public let subject: Subject?
public let files: [Int64: TelegramMediaFile]
public let isDark: Bool
public let avatarCenter: CGPoint
public let avatarSize: CGSize
public let avatarScale: CGFloat
public let defaultHeight: CGFloat
public let gradientOnTop: Bool
public let gradientCenter: CGPoint
public let avatarTransitionFraction: CGFloat
public let patternTransitionFraction: CGFloat
public init(
context: AccountContext,
subject: Subject?,
files: [Int64: TelegramMediaFile],
isDark: Bool,
avatarCenter: CGPoint,
avatarSize: CGSize = CGSize(width: 100.0, height: 100.0),
avatarScale: CGFloat,
defaultHeight: CGFloat,
gradientOnTop: Bool = false,
gradientCenter: CGPoint = CGPoint(x: 0.5, y: 0.5),
avatarTransitionFraction: CGFloat,
patternTransitionFraction: CGFloat
) {
self.context = context
self.subject = subject
self.files = files
self.isDark = isDark
self.avatarCenter = avatarCenter
self.avatarSize = avatarSize
self.avatarScale = avatarScale
self.defaultHeight = defaultHeight
self.gradientOnTop = gradientOnTop
self.gradientCenter = gradientCenter
self.avatarTransitionFraction = avatarTransitionFraction
self.patternTransitionFraction = patternTransitionFraction
}
public static func ==(lhs: PeerInfoCoverComponent, rhs: PeerInfoCoverComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.subject != rhs.subject {
return false
}
if lhs.files != rhs.files {
return false
}
if lhs.isDark != rhs.isDark {
return false
}
if lhs.avatarCenter != rhs.avatarCenter {
return false
}
if lhs.avatarSize != rhs.avatarSize {
return false
}
if lhs.avatarScale != rhs.avatarScale {
return false
}
if lhs.defaultHeight != rhs.defaultHeight {
return false
}
if lhs.gradientOnTop != rhs.gradientOnTop {
return false
}
if lhs.gradientCenter != rhs.gradientCenter {
return false
}
if lhs.avatarTransitionFraction != rhs.avatarTransitionFraction {
return false
}
if lhs.patternTransitionFraction != rhs.patternTransitionFraction {
return false
}
return true
}
public final class View: UIView {
private let backgroundView: UIView
private let backgroundGradientLayer: SimpleGradientLayer
private let avatarBackgroundPatternContentsLayer: SimpleGradientLayer
private let avatarBackgroundPatternMaskLayer: SimpleLayer
private let avatarBackgroundGradientLayer: SimpleGradientLayer
private let backgroundPatternContainer: UIView
private var currentSize: CGSize?
private var component: PeerInfoCoverComponent?
private var state: EmptyComponentState?
private var patternContentsTarget: PatternContentsTarget?
private var avatarPatternContentLayers: [SimpleLayer] = []
private var patternFile: TelegramMediaFile?
private var patternFileDisposable: Disposable?
private var patternImageDisposable: Disposable?
override public init(frame: CGRect) {
self.backgroundView = UIView()
self.backgroundGradientLayer = SimpleGradientLayer()
self.avatarBackgroundGradientLayer = SimpleGradientLayer()
self.avatarBackgroundGradientLayer.opacity = 0.0
let baseAvatarGradientAlpha: CGFloat = 0.4
let numSteps = 6
self.avatarBackgroundGradientLayer.colors = (0 ..< numSteps).map { i in
let step: CGFloat = 1.0 - CGFloat(i) / CGFloat(numSteps - 1)
return UIColor(white: 1.0, alpha: baseAvatarGradientAlpha * pow(step, 2.0)).cgColor
}
self.avatarBackgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.avatarBackgroundGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.avatarBackgroundGradientLayer.type = .radial
self.avatarBackgroundPatternContentsLayer = SimpleGradientLayer()
self.avatarBackgroundPatternContentsLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.avatarBackgroundPatternContentsLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.avatarBackgroundPatternContentsLayer.type = .radial
self.avatarBackgroundPatternMaskLayer = SimpleLayer()
self.backgroundPatternContainer = UIView()
super.init(frame: frame)
self.clipsToBounds = true
self.addSubview(self.backgroundView)
self.layer.addSublayer(self.backgroundGradientLayer)
self.layer.addSublayer(self.avatarBackgroundGradientLayer)
self.avatarBackgroundPatternContentsLayer.mask = self.avatarBackgroundPatternMaskLayer
self.layer.addSublayer(self.avatarBackgroundPatternContentsLayer)
self.addSubview(self.backgroundPatternContainer)
self.layer.allowsGroupOpacity = true
self.backgroundView.layer.allowsGroupOpacity = true
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.patternFileDisposable?.dispose()
self.patternImageDisposable?.dispose()
}
public func willAnimateIn() {
for layer in self.avatarPatternContentLayers {
layer.opacity = 0.0
}
}
public func animateIn() {
guard let _ = self.currentSize, let component = self.component else {
return
}
for layer in self.avatarPatternContentLayers {
layer.opacity = 1.0
layer.animatePosition(
from: component.avatarCenter,
to: layer.position,
duration: 0.4,
timingFunction: kCAMediaTimingFunctionSpring
)
}
}
public func animateSwipeTransition() {
if let gradientSnapshotLayer = self.backgroundGradientLayer.snapshotContentTree() {
let backgroundSnapshotLayer = SimpleLayer()
backgroundSnapshotLayer.masksToBounds = true
backgroundSnapshotLayer.allowsGroupOpacity = true
backgroundSnapshotLayer.backgroundColor = self.backgroundView.backgroundColor?.cgColor
backgroundSnapshotLayer.frame = self.backgroundView.frame
self.layer.insertSublayer(backgroundSnapshotLayer, above: self.backgroundGradientLayer)
gradientSnapshotLayer.frame = self.backgroundGradientLayer.convert(self.backgroundGradientLayer.bounds, to: self.backgroundView.layer)
backgroundSnapshotLayer.addSublayer(gradientSnapshotLayer)
let mask = CAGradientLayer()
mask.startPoint = CGPoint(x: 0.0, y: 0.5)
mask.endPoint = CGPoint(x: 1.0, y: 0.5)
mask.frame = CGRect(origin: CGPoint(x: backgroundSnapshotLayer.bounds.width, y: 0.0), size: CGSize(width: backgroundSnapshotLayer.bounds.width * 2.0, height: backgroundSnapshotLayer.bounds.height))
mask.colors = [
UIColor.white.withAlphaComponent(0.0).cgColor,
UIColor.white.cgColor,
UIColor.white.cgColor,
]
mask.locations = [0.0, 0.5, 1.0]
backgroundSnapshotLayer.mask = mask
mask.animatePosition(from: CGPoint(x: -backgroundSnapshotLayer.bounds.width * 2.0, y: 0.0), to: .zero, duration: 0.35, timingFunction: CAMediaTimingFunctionName.linear.rawValue, additive: true, completion: { [weak backgroundSnapshotLayer] _ in
backgroundSnapshotLayer?.removeFromSuperlayer()
})
}
}
public func animateTransition(background: Bool = true, bounce: Bool = true) {
if background, let gradientSnapshotLayer = self.backgroundGradientLayer.snapshotContentTree() {
let backgroundSnapshotLayer = SimpleLayer()
backgroundSnapshotLayer.allowsGroupOpacity = true
backgroundSnapshotLayer.backgroundColor = self.backgroundView.backgroundColor?.cgColor
backgroundSnapshotLayer.frame = self.backgroundView.frame
self.layer.insertSublayer(backgroundSnapshotLayer, above: self.backgroundGradientLayer)
backgroundSnapshotLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
backgroundSnapshotLayer.removeFromSuperlayer()
})
gradientSnapshotLayer.frame = self.backgroundGradientLayer.convert(self.backgroundGradientLayer.bounds, to: self.backgroundView.layer)
backgroundSnapshotLayer.addSublayer(gradientSnapshotLayer)
}
for layer in self.avatarPatternContentLayers {
if let _ = layer.contents, let snapshot = layer.snapshotContentTree() {
layer.superlayer?.addSublayer(snapshot)
snapshot.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
snapshot.removeFromSuperlayer()
})
}
layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
if bounce {
let values: [NSNumber] = [1.0, 1.14, 1.0]
self.avatarBackgroundPatternContentsLayer.animateKeyframes(values: values, duration: 0.4, keyPath: "sublayerTransform.scale")
}
}
private func loadPatternFromFile() {
guard let component = self.component else {
return
}
guard let patternContentsTarget = self.patternContentsTarget else {
return
}
guard let patternFile = self.patternFile else {
return
}
if component.context.animationRenderer.loadFirstFrameSynchronously(target: patternContentsTarget, cache: component.context.animationCache, itemId: patternFile.resource.id.stringRepresentation, size: CGSize(width: 96, height: 96)) {
self.updatePatternLayerImages(animated: false)
} else {
let animated = self.patternContentsTarget?.contents == nil
self.patternImageDisposable = component.context.animationRenderer.loadFirstFrame(
target: patternContentsTarget,
cache: component.context.animationCache,
itemId: patternFile.resource.id.stringRepresentation,
size: CGSize(width: 96, height: 96),
fetch: animationCacheFetchFile(
postbox: component.context.account.postbox,
userLocation: .other,
userContentType: .sticker,
resource: .media(media: .standalone(media: patternFile), resource: patternFile.resource),
type: AnimationCacheAnimationType(file: patternFile),
keyframeOnly: false,
customColor: .white
),
completion: { [weak self] _, _ in
guard let self else {
return
}
self.updatePatternLayerImages(animated: animated)
}
)
}
}
private func updatePatternLayerImages(animated: Bool) {
let image = self.patternContentsTarget?.contents
for patternContentLayer in self.avatarPatternContentLayers {
patternContentLayer.contents = image
if image != nil && animated {
patternContentLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
}
func update(component: PeerInfoCoverComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.currentSize = availableSize
if previousComponent?.subject?.fileId != component.subject?.fileId {
if let fileId = component.subject?.fileId, fileId != 0 {
if self.patternContentsTarget == nil {
self.patternContentsTarget = PatternContentsTarget(imageUpdated: { [weak self] hadContents in
guard let self else {
return
}
self.updatePatternLayerImages(animated: !hadContents)
})
}
self.patternFile = nil
self.patternFileDisposable?.dispose()
self.patternFileDisposable = nil
self.patternImageDisposable?.dispose()
if let file = component.files[fileId] {
self.patternFile = file
self.loadPatternFromFile()
} else {
self.patternFileDisposable = (component.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> deliverOnMainQueue).startStrict(next: { [weak self] files in
guard let self else {
return
}
if let file = files[fileId] {
self.patternFile = file
self.loadPatternFromFile()
}
})
}
} else {
self.patternContentsTarget = nil
self.patternFileDisposable?.dispose()
self.patternFileDisposable = nil
self.patternFile = nil
self.updatePatternLayerImages(animated: false)
}
}
self.state = state
let backgroundColor: UIColor
let secondaryBackgroundColor: UIColor
if let subject = component.subject, let colors = subject.colors(context: component.context, isDark: component.isDark) {
backgroundColor = colors.0
secondaryBackgroundColor = colors.1
} else {
backgroundColor = .clear
secondaryBackgroundColor = .clear
}
let gradientWidth: CGFloat
let gradientHeight: CGFloat = component.defaultHeight
if case .custom = component.subject {
gradientWidth = gradientHeight
self.backgroundView.backgroundColor = backgroundColor
self.backgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: component.avatarCenter.y / gradientHeight)
self.backgroundGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.backgroundGradientLayer.type = .radial
self.backgroundGradientLayer.colors = [secondaryBackgroundColor.cgColor, backgroundColor.cgColor]
} else if case .status = component.subject {
gradientWidth = availableSize.width
self.backgroundView.backgroundColor = secondaryBackgroundColor
self.backgroundGradientLayer.startPoint = component.gradientCenter
self.backgroundGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.backgroundGradientLayer.type = .radial
self.backgroundGradientLayer.colors = [backgroundColor.cgColor, secondaryBackgroundColor.cgColor]
} else {
gradientWidth = availableSize.width
self.backgroundView.backgroundColor = secondaryBackgroundColor
self.backgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0)
self.backgroundGradientLayer.endPoint = CGPoint(x: 0.5, y: 0.0)
self.backgroundGradientLayer.type = .axial
self.backgroundGradientLayer.colors = [backgroundColor.cgColor, secondaryBackgroundColor.cgColor]
}
self.backgroundGradientLayer.anchorPoint = CGPoint(x: 0.0, y: 1.0)
let backgroundGradientFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - gradientWidth) / 2.0), y: component.gradientOnTop ? 0.0 : availableSize.height - gradientHeight), size: CGSize(width: gradientWidth, height: gradientHeight))
if !transition.animation.isImmediate {
let previousPosition = self.backgroundGradientLayer.position
let updatedPosition = CGPoint(x: backgroundGradientFrame.minX, y: backgroundGradientFrame.maxY)
self.backgroundGradientLayer.bounds = CGRect(origin: CGPoint(), size: backgroundGradientFrame.size)
self.backgroundGradientLayer.position = updatedPosition
transition.containedViewLayoutTransition.animatePositionAdditive(layer: self.backgroundGradientLayer, offset: CGPoint(x: previousPosition.x - updatedPosition.x, y: previousPosition.y - updatedPosition.y))
} else {
self.backgroundGradientLayer.frame = backgroundGradientFrame
}
let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -1000.0 + availableSize.height), size: CGSize(width: availableSize.width, height: 1000.0))
transition.containedViewLayoutTransition.updateFrameAdditive(view: self.backgroundView, frame: backgroundFrame)
let avatarPatternFrame = CGSize(width: 380.0, height: floor(component.defaultHeight * 1.0)).centered(around: component.avatarCenter)
transition.setFrame(layer: self.avatarBackgroundPatternContentsLayer, frame: avatarPatternFrame)
if case let .custom(_, _, patternColor, _) = component.subject, let patternColor {
self.avatarBackgroundPatternContentsLayer.compositingFilter = nil
self.avatarBackgroundPatternContentsLayer.colors = [
patternColor.withAlphaComponent(0.6).cgColor,
patternColor.withAlphaComponent(0.0).cgColor
]
} else if case let .status(status) = component.subject, case let .starGift(_, _, _, _, _, _, _, patternColorValue, _) = status.content {
let patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue))
self.avatarBackgroundPatternContentsLayer.compositingFilter = nil
self.avatarBackgroundPatternContentsLayer.colors = [
patternColor.withAlphaComponent(0.6).cgColor,
patternColor.withAlphaComponent(0.0).cgColor
]
} else {
if component.subject?.colors(context: component.context, isDark: component.isDark) != nil {
self.avatarBackgroundPatternContentsLayer.compositingFilter = "overlayBlendMode"
self.avatarBackgroundPatternContentsLayer.colors = [
UIColor(white: 0.0, alpha: 0.6).cgColor,
UIColor(white: 0.0, alpha: 0.0).cgColor
]
} else {
self.avatarBackgroundPatternContentsLayer.compositingFilter = nil
let baseWhite: CGFloat = component.isDark ? 0.5 : 0.3
self.avatarBackgroundPatternContentsLayer.colors = [
UIColor(white: baseWhite, alpha: 0.6).cgColor,
UIColor(white: baseWhite, alpha: 0.0).cgColor
]
}
}
switch component.subject {
case .custom, .status:
self.avatarBackgroundGradientLayer.isHidden = true
default:
self.avatarBackgroundGradientLayer.isHidden = component.subject?.colors(context: component.context, isDark: component.isDark) == nil
}
transition.setFrame(layer: self.avatarBackgroundGradientLayer, frame: CGSize(width: 300.0, height: 300.0).centered(around: component.avatarCenter))
transition.setAlpha(layer: self.avatarBackgroundGradientLayer, alpha: 1.0 - component.avatarTransitionFraction)
let backgroundPatternContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height), size: CGSize(width: availableSize.width, height: 0.0))
transition.containedViewLayoutTransition.updateFrameAdditive(view: self.backgroundPatternContainer, frame: backgroundPatternContainerFrame)
transition.setAlpha(view: self.backgroundPatternContainer, alpha: component.patternTransitionFraction)
var baseDistance: CGFloat = component.avatarSize.width / 2.0 + 22.0
var baseRowDistance: CGFloat = 28.0
var baseItemSize: CGFloat = 26.0
if availableSize.width <= 60.0 {
baseDistance *= 0.35
baseRowDistance *= 0.3
baseItemSize *= 0.4
} else if availableSize.width < 150.0 {
baseDistance *= 0.6
baseRowDistance *= 0.6
baseItemSize *= 0.83
}
var avatarBackgroundPatternLayerCount = 0
let lokiRng = LokiRng(seed0: 123, seed1: 0, seed2: 0)
let numRows = 5
for row in 0 ..< numRows {
let avatarPatternCount: Int = 7
let avatarPatternAngleSpan: CGFloat = CGFloat.pi * 2.0 / CGFloat(avatarPatternCount - 1)
for i in 0 ..< avatarPatternCount - 1 {
let baseItemDistance: CGFloat = baseDistance + CGFloat(row) * baseRowDistance
let itemDistanceFraction = max(0.0, min(1.0, baseItemDistance / (baseDistance * 2.0)))
let itemScaleFraction = patternScaleValueAt(fraction: component.avatarTransitionFraction * 1.6, t: itemDistanceFraction, reverse: false)
let itemDistance = baseItemDistance * (1.0 - itemScaleFraction) + 20.0 * itemScaleFraction
var itemAngle: CGFloat
itemAngle = -CGFloat.pi * 0.5 + CGFloat(i) * avatarPatternAngleSpan
if row % 2 != 0 {
itemAngle += avatarPatternAngleSpan * 0.5
}
let itemPosition = CGPoint(x: avatarPatternFrame.width * 0.5 + cos(itemAngle) * itemDistance, y: avatarPatternFrame.height * 0.5 + sin(itemAngle) * itemDistance)
var itemScale: CGFloat
itemScale = 0.7 + CGFloat(lokiRng.next()) * (1.0 - 0.7)
let itemSize: CGFloat = floor(baseItemSize * itemScale)
let itemFrame = CGSize(width: itemSize, height: itemSize).centered(around: itemPosition)
let itemLayer: SimpleLayer
if self.avatarPatternContentLayers.count > avatarBackgroundPatternLayerCount {
itemLayer = self.avatarPatternContentLayers[avatarBackgroundPatternLayerCount]
} else {
itemLayer = SimpleLayer()
itemLayer.contents = self.patternContentsTarget?.contents
self.avatarBackgroundPatternMaskLayer.addSublayer(itemLayer)
self.avatarPatternContentLayers.append(itemLayer)
}
itemLayer.frame = itemFrame
itemLayer.layerTintColor = UIColor(white: 0.0, alpha: 0.8).cgColor
transition.setAlpha(layer: itemLayer, alpha: 1.0 - itemScaleFraction)
avatarBackgroundPatternLayerCount += 1
}
}
if avatarBackgroundPatternLayerCount > self.avatarPatternContentLayers.count {
for i in avatarBackgroundPatternLayerCount ..< self.avatarPatternContentLayers.count {
self.avatarPatternContentLayers[i].removeFromSuperlayer()
}
self.avatarPatternContentLayers.removeSubrange(avatarBackgroundPatternLayerCount ..< self.avatarPatternContentLayers.count)
}
return availableSize
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,911 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import AnimationCache
import MultiAnimationRenderer
import TelegramCore
import AccountContext
import SwiftSignalKit
import EmojiTextAttachmentView
import LokiRng
import TextFormat
import HierarchyTrackingLayer
public final class PeerInfoGiftsCoverComponent: Component {
public let context: AccountContext
public let peerId: EnginePeer.Id
public let giftsContext: ProfileGiftsContext
public let hasBackground: Bool
public let avatarCenter: CGPoint
public let avatarSize: CGSize
public let defaultHeight: CGFloat
public let avatarTransitionFraction: CGFloat
public let statusBarHeight: CGFloat
public let topLeftButtonsSize: CGSize
public let topRightButtonsSize: CGSize
public let titleWidth: CGFloat
public let bottomHeight: CGFloat
public let action: (ProfileGiftsContext.State.StarGift) -> Void
public init(
context: AccountContext,
peerId: EnginePeer.Id,
giftsContext: ProfileGiftsContext,
hasBackground: Bool,
avatarCenter: CGPoint,
avatarSize: CGSize,
defaultHeight: CGFloat,
avatarTransitionFraction: CGFloat,
statusBarHeight: CGFloat,
topLeftButtonsSize: CGSize,
topRightButtonsSize: CGSize,
titleWidth: CGFloat,
bottomHeight: CGFloat,
action: @escaping (ProfileGiftsContext.State.StarGift) -> Void
) {
self.context = context
self.peerId = peerId
self.giftsContext = giftsContext
self.hasBackground = hasBackground
self.avatarCenter = avatarCenter
self.avatarSize = avatarSize
self.defaultHeight = defaultHeight
self.avatarTransitionFraction = avatarTransitionFraction
self.statusBarHeight = statusBarHeight
self.topLeftButtonsSize = topLeftButtonsSize
self.topRightButtonsSize = topRightButtonsSize
self.titleWidth = titleWidth
self.bottomHeight = bottomHeight
self.action = action
}
public static func ==(lhs: PeerInfoGiftsCoverComponent, rhs: PeerInfoGiftsCoverComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.hasBackground != rhs.hasBackground {
return false
}
if lhs.avatarCenter != rhs.avatarCenter {
return false
}
if lhs.avatarSize != rhs.avatarSize {
return false
}
if lhs.defaultHeight != rhs.defaultHeight {
return false
}
if lhs.avatarTransitionFraction != rhs.avatarTransitionFraction {
return false
}
if lhs.statusBarHeight != rhs.statusBarHeight {
return false
}
if lhs.topLeftButtonsSize != rhs.topLeftButtonsSize {
return false
}
if lhs.topRightButtonsSize != rhs.topRightButtonsSize {
return false
}
if lhs.titleWidth != rhs.titleWidth {
return false
}
if lhs.bottomHeight != rhs.bottomHeight {
return false
}
return true
}
public final class View: UIView {
private var currentSize: CGSize?
private var component: PeerInfoGiftsCoverComponent?
private var state: EmptyComponentState?
private var giftsDisposable: Disposable?
private var gifts: [ProfileGiftsContext.State.StarGift] = []
private var appliedGiftIds: [Int64] = []
private var iconLayers: [AnyHashable: GiftIconLayer] = [:]
private var iconPositions: [PositionGenerator.Position] = []
private let seed = UInt(Date().timeIntervalSince1970)
private let trackingLayer = HierarchyTrackingLayer()
private var isCurrentlyInHierarchy = false
private var isUpdating = false
override public init(frame: CGRect) {
super.init(frame: frame)
self.clipsToBounds = true
self.layer.addSublayer(self.trackingLayer)
self.trackingLayer.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.isCurrentlyInHierarchy = true
self.updateAnimations()
}
self.trackingLayer.didExitHierarchy = { [weak self] in
guard let self else {
return
}
self.isCurrentlyInHierarchy = false
}
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped(_:))))
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.giftsDisposable?.dispose()
}
@objc private func tapped(_ gestureRecognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
let location = gestureRecognizer.location(in: self)
for (_, iconLayer) in self.iconLayers {
if iconLayer.frame.contains(location) {
component.action(iconLayer.gift)
break
}
}
}
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for (_, iconLayer) in self.iconLayers {
if iconLayer.frame.contains(point) {
return true
}
}
return false
}
func updateAnimations() {
var index = 0
for (_, iconLayer) in self.iconLayers {
if self.isCurrentlyInHierarchy {
iconLayer.startAnimations(index: index)
}
index += 1
}
}
private var scheduledAnimateIn = false
public func willAnimateIn() {
self.scheduledAnimateIn = true
for (_, layer) in self.iconLayers {
layer.opacity = 0.0
}
}
public func animateIn() {
guard let _ = self.currentSize, let component = self.component else {
return
}
self.scheduledAnimateIn = false
for (_, layer) in self.iconLayers {
layer.opacity = 1.0
layer.animatePosition(
from: component.avatarCenter,
to: layer.position,
duration: 0.4,
timingFunction: kCAMediaTimingFunctionSpring
)
}
}
func update(component: PeerInfoGiftsCoverComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
self.state = state
let previousCurrentSize = self.currentSize
self.currentSize = availableSize
let iconSize = CGSize(width: 32.0, height: 32.0)
let giftIds = self.gifts.map { gift in
if case let .unique(gift) = gift.gift {
return gift.id
} else {
return 0
}
}
if !giftIds.isEmpty && (self.iconPositions.isEmpty || previousCurrentSize?.width != availableSize.width || (previousComponent != nil && previousComponent?.hasBackground != component.hasBackground) || self.appliedGiftIds != giftIds) {
var avatarCenter = component.avatarCenter
if avatarCenter.y < 0.0 {
avatarCenter.y = component.statusBarHeight + 75.0
}
var excludeRects: [CGRect] = []
if component.statusBarHeight > 0.0 {
excludeRects.append(CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: component.statusBarHeight + 4.0)))
}
excludeRects.append(CGRect(origin: CGPoint(x: 0.0, y: component.statusBarHeight), size: component.topLeftButtonsSize))
excludeRects.append(CGRect(origin: CGPoint(x: availableSize.width - component.topRightButtonsSize.width, y: component.statusBarHeight), size: component.topRightButtonsSize))
excludeRects.append(CGRect(origin: CGPoint(x: floor((availableSize.width - component.titleWidth) / 2.0), y: avatarCenter.y + component.avatarSize.height / 2.0 + 6.0), size: CGSize(width: component.titleWidth, height: 100.0)))
if component.bottomHeight > 0.0 {
excludeRects.append(CGRect(origin: CGPoint(x: 0.0, y: component.defaultHeight - component.bottomHeight), size: CGSize(width: availableSize.width, height: component.bottomHeight)))
}
let positionGenerator = PositionGenerator(
containerSize: CGSize(width: availableSize.width, height: component.defaultHeight),
centerFrame: component.avatarSize.centered(around: avatarCenter),
exclusionZones: excludeRects,
minimumDistance: 42.0,
edgePadding: 5.0,
seed: self.seed
)
self.iconPositions = positionGenerator.generatePositions(count: 12, itemSize: iconSize)
}
self.appliedGiftIds = giftIds
if self.giftsDisposable == nil {
self.giftsDisposable = combineLatest(
queue: Queue.mainQueue(),
component.giftsContext.state,
component.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId))
|> map { peer -> Int64? in
if case let .user(user) = peer, case let .starGift(id, _, _, _, _, _, _, _, _) = user.emojiStatus?.content {
return id
}
return nil
}
|> distinctUntilChanged
).start(next: { [weak self] state, giftStatusId in
guard let self else {
return
}
let pinnedGifts = state.gifts.filter { gift in
if gift.pinnedToTop {
if case let .unique(uniqueGift) = gift.gift {
return uniqueGift.id != giftStatusId
}
}
return false
}
self.gifts = pinnedGifts
if !self.isUpdating {
self.state?.updated(transition: .spring(duration: 0.4))
}
})
}
var validIds = Set<AnyHashable>()
var index = 0
for gift in self.gifts.prefix(12) {
guard index < self.iconPositions.count else {
break
}
let id: AnyHashable
if case let .unique(uniqueGift) = gift.gift {
id = uniqueGift.slug
} else {
id = index
}
validIds.insert(id)
var iconTransition = transition
let iconPosition = self.iconPositions[index]
let iconLayer: GiftIconLayer
if let current = self.iconLayers[id] {
iconLayer = current
} else {
iconTransition = .immediate
iconLayer = GiftIconLayer(context: component.context, gift: gift, size: iconSize, glowing: component.hasBackground)
self.iconLayers[id] = iconLayer
self.layer.addSublayer(iconLayer)
if self.scheduledAnimateIn {
iconLayer.opacity = 0.0
} else {
iconLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
iconLayer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
}
iconLayer.startAnimations(index: index)
}
iconLayer.glowing = component.hasBackground
let itemDistanceFraction = max(0.0, min(0.5, (iconPosition.distance - component.avatarSize.width / 2.0) / 144.0))
let itemScaleFraction = patternScaleValueAt(fraction: min(1.0, component.avatarTransitionFraction * 1.33), t: itemDistanceFraction, reverse: false)
func interpolatePosition(from: PositionGenerator.Position, to: PositionGenerator.Position, t: CGFloat) -> PositionGenerator.Position {
let clampedT = max(0, min(1, t))
let interpolatedDistance = from.distance + (to.distance - from.distance) * clampedT
let interpolatedAngle = from.angle + (to.angle - from.angle) * clampedT
return PositionGenerator.Position(distance: interpolatedDistance, angle: interpolatedAngle, scale: from.scale)
}
let toAngle: CGFloat = .pi * 0.18
let centerPosition = PositionGenerator.Position(distance: 0.0, angle: iconPosition.angle + toAngle, scale: iconPosition.scale)
let effectivePosition = interpolatePosition(from: iconPosition, to: centerPosition, t: itemScaleFraction)
let effectiveAngle = toAngle * itemScaleFraction
let absolutePosition = getAbsolutePosition(position: effectivePosition, centerPoint: component.avatarCenter)
iconTransition.setBounds(layer: iconLayer, bounds: CGRect(origin: .zero, size: iconSize))
iconTransition.setPosition(layer: iconLayer, position: absolutePosition)
iconLayer.updateRotation(effectiveAngle, transition: iconTransition)
iconTransition.setScale(layer: iconLayer, scale: iconPosition.scale * (1.0 - itemScaleFraction))
if !self.scheduledAnimateIn {
iconTransition.setAlpha(layer: iconLayer, alpha: 1.0 - itemScaleFraction)
}
index += 1
}
var removeIds: [AnyHashable] = []
for (id, layer) in self.iconLayers {
if !validIds.contains(id) {
removeIds.append(id)
layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
layer.removeFromSuperlayer()
})
}
}
for id in removeIds {
self.iconLayers.removeValue(forKey: id)
}
return availableSize
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private var shadowImage: UIImage? = {
return generateImage(CGSize(width: 44.0, height: 44.0), rotatedContext: { size, context in
context.clear(CGRect(origin: .zero, size: size))
var locations: [CGFloat] = [0.0, 0.3, 1.0]
let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.65).cgColor, UIColor(rgb: 0xffffff, alpha: 0.65).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: size.width / 2.0, y: size.height / 2.0), startRadius: 0.0, endCenter: CGPoint(x: size.width / 2.0, y: size.height / 2.0), endRadius: size.width / 2.0, options: .drawsAfterEndLocation)
})
}()
private final class StarsEffectLayer: SimpleLayer {
private let emitterLayer = CAEmitterLayer()
override init() {
super.init()
self.addSublayer(self.emitterLayer)
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup(color: UIColor, size: CGSize) {
self.color = color
let emitter = CAEmitterCell()
emitter.name = "emitter"
emitter.contents = UIImage(bundleImageName: "Premium/Stars/Particle")?.cgImage
emitter.birthRate = 8.0
emitter.lifetime = 2.0
emitter.velocity = 0.1
emitter.scale = (size.width / 40.0) * 0.12
emitter.scaleRange = 0.02
emitter.alphaRange = 0.1
emitter.emissionRange = .pi * 2.0
let staticColors: [Any] = [
color.withAlphaComponent(0.0).cgColor,
color.withAlphaComponent(0.58).cgColor,
color.withAlphaComponent(0.58).cgColor,
color.withAlphaComponent(0.0).cgColor
]
let staticColorBehavior = CAEmitterCell.createEmitterBehavior(type: "colorOverLife")
staticColorBehavior.setValue(staticColors, forKey: "colors")
emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors")
self.emitterLayer.emitterCells = [emitter]
}
private var color: UIColor?
func update(color: UIColor, size: CGSize) {
if self.color != color {
self.setup(color: color, size: size)
}
self.emitterLayer.seed = UInt32.random(in: .min ..< .max)
self.emitterLayer.emitterShape = .circle
self.emitterLayer.emitterSize = size
self.emitterLayer.emitterMode = .surface
self.emitterLayer.frame = CGRect(origin: .zero, size: size)
self.emitterLayer.emitterPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
}
}
private class GiftIconLayer: SimpleLayer {
private let context: AccountContext
let gift: ProfileGiftsContext.State.StarGift
private let size: CGSize
var glowing: Bool {
didSet {
self.shadowLayer.opacity = self.glowing ? 1.0 : 0.0
let color: UIColor
if self.glowing {
color = .white
} else if let layerTintColor = self.shadowLayer.layerTintColor {
color = UIColor(cgColor: layerTintColor)
} else {
color = .white
}
let side = floor(self.size.width * 1.25)
let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: self.size))
self.starsLayer.frame = starsFrame
self.starsLayer.update(color: color, size: starsFrame.size)
}
}
let shadowLayer = SimpleLayer()
let starsLayer = StarsEffectLayer()
let animationLayer: InlineStickerItemLayer
override init(layer: Any) {
guard let layer = layer as? GiftIconLayer else {
fatalError()
}
let context = layer.context
let gift = layer.gift
let size = layer.size
let glowing = layer.glowing
var file: TelegramMediaFile?
var color: UIColor = .white
switch gift.gift {
case let .generic(gift):
file = gift.file
case let .unique(gift):
for attribute in gift.attributes {
if case let .model(_, fileValue, _) = attribute {
file = fileValue
} else if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute {
color = UIColor(rgb: UInt32(bitPattern: innerColor))
}
}
}
let emoji = ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file?.fileId.id ?? 0,
file: file
)
self.animationLayer = InlineStickerItemLayer(
context: .account(context),
userLocation: .other,
attemptSynchronousLoad: false,
emoji: emoji,
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
unique: true,
placeholderColor: UIColor.white.withAlphaComponent(0.2),
pointSize: CGSize(width: size.width * 2.0, height: size.height * 2.0),
loopCount: 1
)
self.shadowLayer.contents = shadowImage?.cgImage
self.shadowLayer.layerTintColor = color.cgColor
self.shadowLayer.opacity = glowing ? 1.0 : 0.0
self.context = context
self.gift = gift
self.size = size
self.glowing = glowing
super.init()
let side = floor(size.width * 1.25)
let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: size))
self.starsLayer.frame = starsFrame
self.starsLayer.update(color: glowing ? .white : color, size: starsFrame.size)
self.addSublayer(self.shadowLayer)
self.addSublayer(self.starsLayer)
self.addSublayer(self.animationLayer)
}
init(
context: AccountContext,
gift: ProfileGiftsContext.State.StarGift,
size: CGSize,
glowing: Bool
) {
self.context = context
self.gift = gift
self.size = size
self.glowing = glowing
var file: TelegramMediaFile?
var color: UIColor = .white
switch gift.gift {
case let .generic(gift):
file = gift.file
case let .unique(gift):
for attribute in gift.attributes {
if case let .model(_, fileValue, _) = attribute {
file = fileValue
} else if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute {
color = UIColor(rgb: UInt32(bitPattern: innerColor))
}
}
}
let emoji = ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file?.fileId.id ?? 0,
file: file
)
self.animationLayer = InlineStickerItemLayer(
context: .account(context),
userLocation: .other,
attemptSynchronousLoad: false,
emoji: emoji,
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
unique: true,
placeholderColor: UIColor.white.withAlphaComponent(0.2),
pointSize: CGSize(width: size.width * 2.0, height: size.height * 2.0),
loopCount: 1
)
self.shadowLayer.contents = shadowImage?.cgImage
self.shadowLayer.layerTintColor = color.cgColor
self.shadowLayer.opacity = glowing ? 1.0 : 0.0
super.init()
let side = floor(size.width * 1.25)
let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: size))
self.starsLayer.frame = starsFrame
self.starsLayer.update(color: glowing ? .white : color, size: starsFrame.size)
self.addSublayer(self.shadowLayer)
self.addSublayer(self.starsLayer)
self.addSublayer(self.animationLayer)
}
required init?(coder: NSCoder) {
preconditionFailure()
}
override func layoutSublayers() {
self.shadowLayer.frame = CGRect(origin: .zero, size: self.bounds.size).insetBy(dx: -8.0, dy: -8.0)
self.animationLayer.bounds = CGRect(origin: .zero, size: self.bounds.size)
self.animationLayer.position = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0)
}
func updateRotation(_ angle: CGFloat, transition: ComponentTransition) {
self.animationLayer.transform = CATransform3DMakeRotation(angle, 0.0, 0.0, 1.0)
}
func startAnimations(index: Int) {
let beginTime = Double(index) * 1.5
if self.animation(forKey: "hover") == nil {
let upDistance = CGFloat.random(in: 1.0 ..< 2.0)
let downDistance = CGFloat.random(in: 1.0 ..< 2.0)
let hoverDuration = TimeInterval.random(in: 3.5 ..< 4.5)
let hoverAnimation = CABasicAnimation(keyPath: "transform.translation.y")
hoverAnimation.duration = hoverDuration
hoverAnimation.fromValue = -upDistance
hoverAnimation.toValue = downDistance
hoverAnimation.autoreverses = true
hoverAnimation.repeatCount = .infinity
hoverAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
hoverAnimation.beginTime = beginTime
hoverAnimation.isAdditive = true
self.add(hoverAnimation, forKey: "hover")
}
if self.animationLayer.animation(forKey: "wiggle") == nil {
let fromRotationAngle = CGFloat.random(in: 0.025 ..< 0.05)
let toRotationAngle = CGFloat.random(in: 0.025 ..< 0.05)
let wiggleDuration = TimeInterval.random(in: 2.0 ..< 3.0)
let wiggleAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
wiggleAnimation.duration = wiggleDuration
wiggleAnimation.fromValue = -fromRotationAngle
wiggleAnimation.toValue = toRotationAngle
wiggleAnimation.autoreverses = true
wiggleAnimation.repeatCount = .infinity
wiggleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
wiggleAnimation.beginTime = beginTime
wiggleAnimation.isAdditive = true
self.animationLayer.add(wiggleAnimation, forKey: "wiggle")
}
if self.shadowLayer.animation(forKey: "glow") == nil {
let glowDuration = TimeInterval.random(in: 2.0 ..< 3.0)
let glowAnimation = CABasicAnimation(keyPath: "transform.scale")
glowAnimation.duration = glowDuration
glowAnimation.fromValue = 1.0
glowAnimation.toValue = 1.2
glowAnimation.autoreverses = true
glowAnimation.repeatCount = .infinity
glowAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
glowAnimation.beginTime = beginTime
self.shadowLayer.add(glowAnimation, forKey: "glow")
}
}
}
private struct PositionGenerator {
struct Position {
let distance: CGFloat
let angle: CGFloat
let scale: CGFloat
var relativeCartesian: CGPoint {
return CGPoint(
x: self.distance * cos(self.angle),
y: self.distance * sin(self.angle)
)
}
}
let containerSize: CGSize
let centerFrame: CGRect
let exclusionZones: [CGRect]
let minimumDistance: CGFloat
let edgePadding: CGFloat
let scaleRange: (min: CGFloat, max: CGFloat)
let innerOrbitRange: (min: CGFloat, max: CGFloat)
let outerOrbitRange: (min: CGFloat, max: CGFloat)
let innerOrbitCount: Int
private let lokiRng: LokiRng
init(
containerSize: CGSize,
centerFrame: CGRect,
exclusionZones: [CGRect],
minimumDistance: CGFloat,
edgePadding: CGFloat,
seed: UInt,
scaleRange: (min: CGFloat, max: CGFloat) = (0.7, 1.15),
innerOrbitRange: (min: CGFloat, max: CGFloat) = (1.4, 2.2),
outerOrbitRange: (min: CGFloat, max: CGFloat) = (2.5, 3.6),
innerOrbitCount: Int = 4
) {
self.containerSize = containerSize
self.centerFrame = centerFrame
self.exclusionZones = exclusionZones
self.minimumDistance = minimumDistance
self.edgePadding = edgePadding
self.scaleRange = scaleRange
self.innerOrbitRange = innerOrbitRange
self.outerOrbitRange = outerOrbitRange
self.innerOrbitCount = innerOrbitCount
self.lokiRng = LokiRng(seed0: seed, seed1: 0, seed2: 0)
}
func generatePositions(count: Int, itemSize: CGSize) -> [Position] {
var positions: [Position] = []
let centerPoint = CGPoint(x: self.centerFrame.midX, y: self.centerFrame.midY)
let centerRadius = min(self.centerFrame.width, self.centerFrame.height) / 2.0
let maxAttempts = count * 200
var attempts = 0
var leftPositions = 0
var rightPositions = 0
let innerCount = min(self.innerOrbitCount, count)
while positions.count < innerCount && attempts < maxAttempts {
attempts += 1
let placeOnLeftSide = rightPositions > leftPositions
let orbitRangeSize = self.innerOrbitRange.max - self.innerOrbitRange.min
let orbitDistanceFactor = self.innerOrbitRange.min + orbitRangeSize * CGFloat(self.lokiRng.next())
let distance = orbitDistanceFactor * centerRadius
let angleRange: CGFloat = placeOnLeftSide ? .pi : .pi
let angleOffset: CGFloat = placeOnLeftSide ? .pi/2 : -(.pi/2)
let angle = angleOffset + angleRange * CGFloat(self.lokiRng.next())
let absolutePosition = getAbsolutePosition(distance: distance, angle: angle, centerPoint: centerPoint)
if absolutePosition.x - itemSize.width/2 < self.edgePadding ||
absolutePosition.x + itemSize.width/2 > self.containerSize.width - self.edgePadding ||
absolutePosition.y - itemSize.height/2 < self.edgePadding ||
absolutePosition.y + itemSize.height/2 > self.containerSize.height - self.edgePadding {
continue
}
let itemRect = CGRect(
x: absolutePosition.x - itemSize.width/2,
y: absolutePosition.y - itemSize.height/2,
width: itemSize.width,
height: itemSize.height
)
if self.isValidPosition(itemRect, existingPositions: positions.map {
getAbsolutePosition(distance: $0.distance, angle: $0.angle, centerPoint: centerPoint)
}, itemSize: itemSize) {
let scaleRangeSize = max(self.scaleRange.min + 0.1, 0.75) - self.scaleRange.max
let scale = self.scaleRange.max + scaleRangeSize * CGFloat(self.lokiRng.next())
positions.append(Position(distance: distance, angle: angle, scale: scale))
if absolutePosition.x < centerPoint.x {
leftPositions += 1
} else {
rightPositions += 1
}
}
}
let maxPossibleDistance = hypot(self.containerSize.width, self.containerSize.height) / 2
while positions.count < count && attempts < maxAttempts {
attempts += 1
let placeOnLeftSide = rightPositions >= leftPositions
let orbitRangeSize = self.outerOrbitRange.max - self.outerOrbitRange.min
let orbitDistanceFactor = self.outerOrbitRange.min + orbitRangeSize * CGFloat(self.lokiRng.next())
let distance = orbitDistanceFactor * centerRadius
let angleRange: CGFloat = placeOnLeftSide ? .pi : .pi
let angleOffset: CGFloat = placeOnLeftSide ? .pi/2 : -(.pi/2)
let angle = angleOffset + angleRange * CGFloat(self.lokiRng.next())
let absolutePosition = getAbsolutePosition(distance: distance, angle: angle, centerPoint: centerPoint)
if absolutePosition.x - itemSize.width/2 < self.edgePadding ||
absolutePosition.x + itemSize.width/2 > self.containerSize.width - self.edgePadding ||
absolutePosition.y - itemSize.height/2 < self.edgePadding ||
absolutePosition.y + itemSize.height/2 > self.containerSize.height - self.edgePadding {
continue
}
let itemRect = CGRect(
x: absolutePosition.x - itemSize.width/2,
y: absolutePosition.y - itemSize.height/2,
width: itemSize.width,
height: itemSize.height
)
if self.isValidPosition(itemRect, existingPositions: positions.map {
getAbsolutePosition(distance: $0.distance, angle: $0.angle, centerPoint: centerPoint)
}, itemSize: itemSize) {
let normalizedDistance = min(distance / maxPossibleDistance, 1.0)
let scale = self.scaleRange.max - normalizedDistance * (self.scaleRange.max - self.scaleRange.min)
positions.append(Position(distance: distance, angle: angle, scale: scale))
if absolutePosition.x < centerPoint.x {
leftPositions += 1
} else {
rightPositions += 1
}
}
}
return positions
}
func getAbsolutePosition(distance: CGFloat, angle: CGFloat, centerPoint: CGPoint) -> CGPoint {
return CGPoint(
x: centerPoint.x + distance * cos(angle),
y: centerPoint.y + distance * sin(angle)
)
}
private func isValidPosition(_ rect: CGRect, existingPositions: [CGPoint], itemSize: CGSize) -> Bool {
if rect.minX < self.edgePadding || rect.maxX > self.containerSize.width - self.edgePadding ||
rect.minY < self.edgePadding || rect.maxY > self.containerSize.height - self.edgePadding {
return false
}
for zone in self.exclusionZones {
if rect.intersects(zone) {
return false
}
}
let effectiveMinDistance = existingPositions.count > 5 ? max(self.minimumDistance * 0.7, 10.0) : self.minimumDistance
for existingPosition in existingPositions {
let distance = hypot(existingPosition.x - rect.midX, existingPosition.y - rect.midY)
if distance < effectiveMinDistance {
return false
}
}
return true
}
}
private func getAbsolutePosition(position: PositionGenerator.Position, centerPoint: CGPoint) -> CGPoint {
return CGPoint(
x: centerPoint.x + position.distance * cos(position.angle),
y: centerPoint.y + position.distance * sin(position.angle)
)
}
private func getAbsolutePosition(distance: CGFloat, angle: CGFloat, centerPoint: CGPoint) -> CGPoint {
return CGPoint(
x: centerPoint.x + distance * cos(angle),
y: centerPoint.y + distance * sin(angle)
)
}
private func windowFunction(t: CGFloat) -> CGFloat {
return bezierPoint(0.6, 0.0, 0.4, 1.0, t)
}
private func patternScaleValueAt(fraction: CGFloat, t: CGFloat, reverse: Bool) -> CGFloat {
let windowSize: CGFloat = 0.8
let effectiveT: CGFloat
let windowStartOffset: CGFloat
let windowEndOffset: CGFloat
if reverse {
effectiveT = 1.0 - t
windowStartOffset = 1.0
windowEndOffset = -windowSize
} else {
effectiveT = t
windowStartOffset = -windowSize
windowEndOffset = 1.0
}
let windowPosition = (1.0 - fraction) * windowStartOffset + fraction * windowEndOffset
let windowT = max(0.0, min(windowSize, effectiveT - windowPosition)) / windowSize
let localT = 1.0 - windowFunction(t: windowT)
return localT
}