mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-15 21:45:19 +00:00
903 lines
36 KiB
Swift
903 lines
36 KiB
Swift
import Foundation
|
|
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
|
|
}
|
|
}
|
|
|
|
public func willAnimateIn() {
|
|
for (_, layer) in self.iconLayers {
|
|
layer.opacity = 0.0
|
|
}
|
|
}
|
|
|
|
public func animateIn() {
|
|
guard let _ = self.currentSize, let component = self.component else {
|
|
return
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
let start = CACurrentMediaTime()
|
|
self.iconPositions = positionGenerator.generatePositions(count: 12, itemSize: iconSize)
|
|
print("generated icon positions in \( CACurrentMediaTime() - start )s")
|
|
}
|
|
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)
|
|
|
|
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))
|
|
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
|
|
}
|