Update Ghostgram features

This commit is contained in:
ichmagmaus 812
2026-03-07 18:15:32 +01:00
parent 1a3303b059
commit 24a7ec39d9
902 changed files with 148295 additions and 62348 deletions
@@ -0,0 +1,833 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import AccountContext
import GiftItemComponent
import GlassBackgroundComponent
import GlassBarButtonComponent
import BundleIconComponent
import LottieComponent
private let cubeSide: CGFloat = 110.0
struct GiftItem: Equatable {
let gift: StarGift.UniqueGift
let reference: StarGiftReference
}
final class CraftTableComponent: Component {
enum Result {
case gift(ProfileGiftsContext.State.StarGift)
case fail
}
let context: AccountContext
let gifts: [Int32: GiftItem]
let buttonColor: UIColor
let isCrafting: Bool
let result: Result?
let select: (Int32) -> Void
let remove: (Int32) -> Void
let willFinish: (Bool) -> Void
let finished: (UIView?) -> Void
public init(
context: AccountContext,
gifts: [Int32: GiftItem],
buttonColor: UIColor,
isCrafting: Bool,
result: Result?,
select: @escaping (Int32) -> Void,
remove: @escaping (Int32) -> Void,
willFinish: @escaping (Bool) -> Void,
finished: @escaping (UIView?) -> Void
) {
self.context = context
self.gifts = gifts
self.buttonColor = buttonColor
self.isCrafting = isCrafting
self.result = result
self.select = select
self.remove = remove
self.willFinish = willFinish
self.finished = finished
}
public static func ==(lhs: CraftTableComponent, rhs: CraftTableComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gifts != rhs.gifts {
return false
}
if lhs.buttonColor != rhs.buttonColor {
return false
}
if lhs.isCrafting != rhs.isCrafting {
return false
}
return true
}
public final class View: UIView {
private var selectedGifts: [AnyHashable: ComponentView<Empty>] = [:]
private var faces: [AnyHashable: ComponentView<Empty>] = [:]
private let successFace = ComponentView<Empty>()
private let anvilPlayOnce = ActionSlot<Void>()
private let animationView = CubeAnimationView()
private let craftFailPlayOnce = ActionSlot<Void>()
private var didSetupFinishAnimation = false
private var flipFaces = false
private var isSuccess = false
private var isFailed = false
private var failDidStartCrossAnimation = false
private var failDidBringToFront = false
private var failWillFinish = false
private var failDidFinish = false
private var component: CraftTableComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.animationView)
self.animationView.onStickerLaunch = {
HapticFeedback().impact(.soft)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupFailureAnimation() {
guard !self.didSetupFinishAnimation else {
return
}
self.didSetupFinishAnimation = true
self.animationView.onFinishApproach = { [weak self] isUpsideDown, isClockwise in
guard let self, let component = self.component else {
return
}
self.isFailed = true
self.animationView.setSticker(nil, face: 0, mirror: false)
var availableStickers: [ComponentView<Empty>] = []
for (id, gift) in self.selectedGifts {
if let id = id.base as? Int, component.gifts[Int32(id)] != nil {
availableStickers.append(gift)
}
}
let wrappingCount = min(2, availableStickers.count)
for i in 0 ..< wrappingCount {
if let sticker = availableStickers[i].view {
let face: Int
if isClockwise {
face = i + 1
} else {
face = 3 - i
}
self.animationView.setSticker(sticker, face: face, mirror: isUpsideDown, animated: true)
}
}
self.flipFaces = isUpsideDown
Queue.mainQueue().after(0.3, {
self.failWillFinish = true
self.component?.willFinish(false)
self.craftFailPlayOnce.invoke(Void())
})
Queue.mainQueue().after(0.5, {
self.failDidFinish = true
self.component?.finished(nil)
})
self.state?.updated(transition: .easeInOut(duration: 0.4))
}
}
func setupSuccessAnimation(_ gift: StarGift.UniqueGift) {
guard !self.didSetupFinishAnimation, let component = self.component else {
return
}
self.didSetupFinishAnimation = true
self.animationView.isSuccess = true
self.animationView.onFinishApproach = { [weak self] isUpsideDown, isClockwise in
guard let self else {
return
}
self.isSuccess = true
var availableStickers: [ComponentView<Empty>] = []
for (id, gift) in self.selectedGifts {
if let id = id.base as? Int, component.gifts[Int32(id)] != nil {
availableStickers.append(gift)
}
}
let wrappingCount = min(2, availableStickers.count)
for i in 0 ..< wrappingCount {
if let sticker = availableStickers[i].view {
let face: Int
if isClockwise {
face = i + 1
} else {
face = 3 - i
}
self.animationView.setSticker(sticker, face: face, mirror: isUpsideDown, animated: true)
}
}
self.flipFaces = isUpsideDown
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let _ = self.successFace.update(
transition: .immediate,
component: AnyComponent(
GiftItemComponent(
context: component.context,
style: .glass,
theme: presentationData.theme,
strings: presentationData.strings,
peer: nil,
subject: .uniqueGift(gift: gift, price: nil),
ribbon: nil,
resellPrice: nil,
isHidden: false,
isSelected: false,
isPinned: false,
isEditing: false,
mode: .grid,
cornerRadius: 28.0,
action: nil,
contextAction: nil
)
),
environment: {},
containerSize: CGSize(width: cubeSide, height: cubeSide)
)
if let successView = self.successFace.view as? GiftItemComponent.View {
let backgroundLayer = successView.backgroundLayer
if let patternView = successView.pattern {
backgroundLayer.opacity = 0.0
patternView.alpha = 0.0
Queue.mainQueue().after(1.0, {
let transition = ComponentTransition.easeInOut(duration: 0.3)
transition.animateBlur(layer: backgroundLayer, fromRadius: 10.0, toRadius: 0.0)
transition.setAlpha(layer: backgroundLayer, alpha: 1.0)
transition.setAlpha(view: patternView, alpha: 1.0)
transition.animateBlur(layer: patternView.layer, fromRadius: 10.0, toRadius: 0.0)
Queue.mainQueue().after(1.0, {
self.component?.finished(successView)
})
})
}
self.animationView.setSticker(successView, face: 0, mirror: isUpsideDown)
}
self.state?.updated()
}
}
func update(component: CraftTableComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
self.animationView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize)
let permilleValue = component.gifts.reduce(0, { $0 + Int($1.value.gift.craftChancePermille ?? 0) })
for index in 0 ..< 6 {
let face: ComponentView<Empty>
if let current = self.faces[index] {
face = current
} else {
face = ComponentView<Empty>()
self.faces[index] = face
}
let faceComponent: AnyComponent<Empty>
var faceItems: [AnyComponentWithIdentity<Empty>] = []
if index == 0 {
faceItems.append(
AnyComponentWithIdentity(id: "background", component: AnyComponent(
CubeFaceComponent(color: component.buttonColor, cornerRadius: 28.0)
))
)
if !component.isCrafting || self.isFailed {
faceItems.append(
AnyComponentWithIdentity(id: "glass", component: AnyComponent(
GlassBackgroundComponent(size: CGSize(width: cubeSide, height: cubeSide), cornerRadius: 28.0, isDark: true, tintColor: .init(kind: .custom, color: component.buttonColor))
))
)
}
if self.isFailed {
faceItems.append(
AnyComponentWithIdentity(id: "faildial", component: AnyComponent(
DialIndicatorComponent(
content: AnyComponentWithIdentity(id: "gift", component: AnyComponent(
LottieComponent(
content: LottieComponent.AppBundleContent(name: "CraftFail"),
color: .white,
size: CGSize(width: 52.0, height: 52.0),
playOnce: self.craftFailPlayOnce
)
)),
backgroundColor: .white.withAlphaComponent(0.1),
foregroundColor: .white,
diameter: 84.0,
contentSize: CGSize(width: 44.0, height: 44.0),
lineWidth: 5.0,
fontSize: 18.0,
progress: 0.0,
value: component.gifts.count,
suffix: "",
isVisible: true,
isFlipped: self.flipFaces
)
))
)
} else if !self.isSuccess {
faceItems.append(
AnyComponentWithIdentity(id: "dial", component: AnyComponent(
DialIndicatorComponent(
content: AnyComponentWithIdentity(id: "empty", component: AnyComponent(Rectangle(color: .clear))),
backgroundColor: .white.withAlphaComponent(0.1),
foregroundColor: .white,
diameter: 84.0,
lineWidth: 5.0,
fontSize: 18.0,
progress: CGFloat(permilleValue) / 10.0 / 100.0,
value: permilleValue / 10,
suffix: "%",
isVisible: !component.isCrafting
)
))
)
faceItems.append(
AnyComponentWithIdentity(id: "icon", component: AnyComponent(
LottieComponent(
content: LottieComponent.AppBundleContent(name: "Anvil"),
size: CGSize(width: 52.0, height: 52.0),
playOnce: self.anvilPlayOnce
)
))
)
}
} else {
faceItems.append(
AnyComponentWithIdentity(id: "background", component: AnyComponent(
CubeFaceComponent(color: component.buttonColor, cornerRadius: 28.0)
))
)
faceItems.append(
AnyComponentWithIdentity(id: "icon", component: AnyComponent(
BundleIconComponent(name: "Components/CubeSide", tintColor: nil, flipVertically: index < 4 ? self.flipFaces : false)
))
)
}
faceComponent = AnyComponent(
ZStack(faceItems)
)
let _ = face.update(
transition: transition,
component: faceComponent,
environment: {},
containerSize: CGSize(width: cubeSide, height: cubeSide)
)
}
if previousComponent == nil {
var faceViews: [UIView] = []
for index in 0 ..< 6 {
if let faceView = self.faces[index]?.view {
faceView.bounds = CGRect(origin: .zero, size: CGSize(width: cubeSide, height: cubeSide))
faceView.clipsToBounds = true
faceView.layer.rasterizationScale = UIScreenScale
faceView.layer.cornerRadius = 28.0
faceViews.append(faceView)
}
}
self.animationView.setFaces(faceViews)
}
var stickerViews: [UIView] = []
for index in 0 ..< 4 {
let itemId = AnyHashable(index)
var itemTransition = transition
let visibleItem: ComponentView<Empty>
if let current = self.selectedGifts[itemId] {
visibleItem = current
} else {
visibleItem = ComponentView()
self.selectedGifts[itemId] = visibleItem
itemTransition = .immediate
}
let gift = component.gifts[Int32(index)]
let _ = visibleItem.update(
transition: itemTransition,
component: AnyComponent(
GiftSlotComponent(
context: component.context,
gift: gift,
buttonColor: component.buttonColor,
isCrafting: component.isCrafting,
action: {
component.select(Int32(index))
},
removeAction: index > 0 ? {
component.remove(Int32(index))
} : nil
)
),
environment: {},
containerSize: CGSize(width: cubeSide, height: cubeSide)
)
if let itemView = visibleItem.view {
stickerViews.append(itemView)
}
}
if previousComponent == nil {
self.animationView.setStickers(stickerViews)
}
if let previousComponent, previousComponent.isCrafting != component.isCrafting {
var indices: [Int] = []
for index in component.gifts.keys.sorted() {
indices.append(Int(index))
}
Queue.mainQueue().after(0.55) {
HapticFeedback().impact(.light)
}
self.anvilPlayOnce.invoke(Void())
Queue.mainQueue().after(0.75, {
self.animationView.startStickerSequence(indices: indices)
switch component.result {
case let .gift(gift):
if case let .unique(uniqueGift) = gift.gift {
self.setupSuccessAnimation(uniqueGift)
}
case .fail:
self.setupFailureAnimation()
default:
break
}
})
}
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)
}
}
final class GiftSlotComponent: Component {
let context: AccountContext
let gift: GiftItem?
let buttonColor: UIColor
let isCrafting: Bool
let action: () -> Void
let removeAction: (() -> Void)?
public init(
context: AccountContext,
gift: GiftItem?,
buttonColor: UIColor,
isCrafting: Bool,
action: @escaping () -> Void,
removeAction: (() -> Void)?
) {
self.context = context
self.gift = gift
self.buttonColor = buttonColor
self.isCrafting = isCrafting
self.action = action
self.removeAction = removeAction
}
public static func ==(lhs: GiftSlotComponent, rhs: GiftSlotComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gift != rhs.gift {
return false
}
if lhs.buttonColor != rhs.buttonColor {
return false
}
if lhs.isCrafting != rhs.isCrafting {
return false
}
return true
}
public final class View: UIView {
private let backgroundView = GlassBackgroundView()
private let addIcon = UIImageView()
private var icon: ComponentView<Empty>?
private let button = HighlightTrackingButton()
private var badge: ComponentView<Empty>?
private var removeIcon: ComponentView<Empty>?
private let removeButton = HighlightTrackingButton()
private var component: GiftSlotComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.addIcon.image = generateAddIcon(backgroundColor: .white)
self.addSubview(self.backgroundView)
self.backgroundView.contentView.addSubview(self.addIcon)
self.backgroundView.contentView.addSubview(self.button)
self.addSubview(self.removeButton)
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
self.removeButton.addTarget(self, action: #selector(self.removeButtonPressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func buttonPressed() {
guard let _ = self.component?.removeAction else {
return
}
self.component?.action()
}
@objc private func removeButtonPressed() {
self.component?.removeAction?()
}
func update(component: GiftSlotComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
let backgroundFrame = CGRect(origin: .zero, size: availableSize).insetBy(dx: 1.0, dy: 1.0)
self.backgroundView.update(size: backgroundFrame.size, cornerRadius: 28.0, isDark: true, tintColor: .init(kind: .custom, color: component.buttonColor), isInteractive: true, transition: .immediate)
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
if component.gift == nil && component.isCrafting && previousComponent?.isCrafting == false {
transition.setBlur(layer: self.backgroundView.layer, radius: 10.0)
self.backgroundView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false)
transition.setBlur(layer: self.addIcon.layer, radius: 10.0)
}
transition.setAlpha(view: self.addIcon, alpha: component.isCrafting ? 0.0 : 1.0)
if let icon = self.addIcon.image {
transition.setFrame(view: self.addIcon, frame: CGRect(origin: CGPoint(x: floor((backgroundFrame.width - icon.size.width) / 2.0), y: floor((backgroundFrame.height - icon.size.height) / 2.0)), size: icon.size))
}
if previousComponent?.gift?.gift.id != component.gift?.gift.id {
if let iconView = self.icon?.view {
if transition.animation.isImmediate {
iconView.removeFromSuperview()
} else {
transition.setScale(view: iconView, scale: 0.01)
transition.setAlpha(view: iconView, alpha: 0.0, completion: { _ in
iconView.removeFromSuperview()
})
}
}
self.icon = nil
}
if (previousComponent?.gift?.gift.id == nil) != (component.gift?.gift.id == nil) || ((previousComponent?.isCrafting ?? false) != component.isCrafting && component.isCrafting) {
if let badgeView = self.badge?.view {
if transition.animation.isImmediate {
badgeView.removeFromSuperview()
} else {
transition.setBlur(layer: badgeView.layer, radius: 10.0)
transition.setAlpha(view: badgeView, alpha: 0.0, completion: { _ in
badgeView.removeFromSuperview()
})
}
}
self.badge = nil
if let removeButtonView = self.removeIcon?.view {
if transition.animation.isImmediate {
removeButtonView.removeFromSuperview()
} else {
transition.setBlur(layer: removeButtonView.layer, radius: 10.0)
transition.setAlpha(view: removeButtonView, alpha: 0.0, completion: { _ in
removeButtonView.removeFromSuperview()
})
}
}
self.removeIcon = nil
}
if let gift = component.gift {
let icon: ComponentView<Empty>
var iconTransition = transition
if let current = self.icon {
icon = current
} else {
iconTransition = .immediate
icon = ComponentView()
self.icon = icon
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let iconSize = icon.update(
transition: iconTransition,
component: AnyComponent(
GiftItemComponent(
context: component.context,
style: .glass,
theme: presentationData.theme,
strings: presentationData.strings,
peer: nil,
subject: .uniqueGift(gift: gift.gift, price: nil),
ribbon: nil,
resellPrice: nil,
isHidden: false,
isSelected: false,
isPinned: false,
isEditing: false,
mode: .grid,
cornerRadius: 28.0,
action: nil,
contextAction: nil
)
),
environment: {},
containerSize: CGSize(width: availableSize.width, height: availableSize.height)
)
let iconFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: iconSize)
if let iconView = icon.view {
if iconView.superview == nil {
iconView.isUserInteractionEnabled = false
if let badgeView = self.badge?.view {
self.backgroundView.contentView.insertSubview(iconView, belowSubview: badgeView)
} else {
self.backgroundView.contentView.addSubview(iconView)
}
if !transition.animation.isImmediate {
transition.animateAlpha(view: iconView, from: 0.0, to: 1.0)
transition.animateScale(view: iconView, from: 0.01, to: 1.0)
}
}
iconTransition.setFrame(view: iconView, frame: iconFrame)
}
if !component.isCrafting {
var buttonColor: UIColor = component.buttonColor
if let backdropAttribute = gift.gift.attributes.first(where: { attribute in
if case .backdrop = attribute {
return true
} else {
return false
}
}), case let .backdrop(_, _, innerColor, _, _, _, _) = backdropAttribute {
buttonColor = UIColor(rgb: UInt32(bitPattern: innerColor)).withMultipliedBrightnessBy(0.65)
}
let badge: ComponentView<Empty>
var badgeTransition = transition
if let current = self.badge {
badge = current
} else {
badgeTransition = .immediate
badge = ComponentView()
self.badge = badge
}
let badgeSize = badge.update(
transition: badgeTransition,
component: AnyComponent(
ZStack([
AnyComponentWithIdentity(id: "background", component: AnyComponent(
RoundedRectangle(color: buttonColor, cornerRadius: 13.5, size: CGSize(width: 54.0, height: 27.0))
)),
AnyComponentWithIdentity(id: "icon", component: AnyComponent(
Text(text: "\((gift.gift.craftChancePermille ?? 0) / 10)%", font: Font.semibold(17.0), color: .white)
))
])
),
environment: {},
containerSize: CGSize(width: 54.0, height: 27.0)
)
let badgeFrame = CGRect(origin: CGPoint(x: -6.0, y: -6.0 - UIScreenPixel), size: badgeSize)
if let badgeView = badge.view {
if badgeView.superview == nil {
badgeView.isUserInteractionEnabled = false
self.backgroundView.contentView.addSubview(badgeView)
if !transition.animation.isImmediate {
transition.animateAlpha(view: badgeView, from: 0.0, to: 1.0)
transition.animateScale(view: badgeView, from: 0.01, to: 1.0)
}
}
badgeTransition.setFrame(view: badgeView, frame: badgeFrame)
}
if let _ = component.removeAction {
let removeButton: ComponentView<Empty>
var removeButtonTransition = transition
if let current = self.removeIcon {
removeButton = current
} else {
removeButtonTransition = .immediate
removeButton = ComponentView()
self.removeIcon = removeButton
}
let removeButtonSize = removeButton.update(
transition: removeButtonTransition,
component: AnyComponent(
ZStack([
AnyComponentWithIdentity(id: "background", component: AnyComponent(
RoundedRectangle(color: buttonColor, cornerRadius: 13.5, size: CGSize(width: 27.0, height: 27.0))
)),
AnyComponentWithIdentity(id: "icon", component: AnyComponent(
BundleIconComponent(name: "Media Gallery/PictureInPictureClose", tintColor: .white)
))
])
),
environment: {},
containerSize: CGSize(width: 27.0, height: 27.0)
)
let removeButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - 21.0, y: -6.0 - UIScreenPixel), size: removeButtonSize)
if let removeButtonView = removeButton.view {
if removeButtonView.superview == nil {
removeButtonView.isUserInteractionEnabled = false
self.backgroundView.contentView.addSubview(removeButtonView)
if !transition.animation.isImmediate {
transition.animateAlpha(view: removeButtonView, from: 0.0, to: 1.0)
transition.animateScale(view: removeButtonView, from: 0.01, to: 1.0)
}
}
removeButtonTransition.setFrame(view: removeButtonView, frame: removeButtonFrame)
}
}
}
}
self.isUserInteractionEnabled = !component.isCrafting
self.button.frame = CGRect(origin: .zero, size: availableSize)
self.removeButton.isUserInteractionEnabled = component.removeAction != nil
if let removeIcon = self.removeIcon?.view {
self.removeButton.frame = removeIcon.frame.insetBy(dx: -8.0, dy: -8.0)
}
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 func generateAddIcon(backgroundColor: UIColor) -> UIImage? {
return generateImage(CGSize(width: 46.0, height: 46.0), contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
context.setFillColor(backgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: .zero, size: size))
context.setBlendMode(.clear)
context.setStrokeColor(UIColor.clear.cgColor)
context.setLineWidth(4.0)
context.setLineCap(.round)
context.move(to: CGPoint(x: 23.0, y: 13.0))
context.addLine(to: CGPoint(x: 23.0, y: 33.0))
context.strokePath()
context.move(to: CGPoint(x: 13.0, y: 23.0))
context.addLine(to: CGPoint(x: 33.0, y: 23.0))
context.strokePath()
})
}
private final class CubeFaceComponent: Component {
private let color: UIColor
private let cornerRadius: CGFloat
public init(color: UIColor, cornerRadius: CGFloat) {
self.color = color
self.cornerRadius = cornerRadius
}
public static func ==(lhs: CubeFaceComponent, rhs: CubeFaceComponent) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
if lhs.cornerRadius != rhs.cornerRadius {
return false
}
return true
}
public final class View: UIView {
override public init(frame: CGRect) {
super.init(frame: frame)
self.clipsToBounds = true
self.layer.cornerCurve = .continuous
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
transition.setBackgroundColor(view: view, color: self.color)
transition.setCornerRadius(layer: view.layer, cornerRadius: self.cornerRadius)
return availableSize
}
}
@@ -0,0 +1,792 @@
import UIKit
import simd
import Display
final class Transform3DView: UIView {
override class var layerClass: AnyClass { CATransformLayer.self }
}
final class PassthroughView: UIView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subview in self.subviews where !subview.isHidden && subview.alpha > 0 && subview.isUserInteractionEnabled {
let converted = self.convert(point, to: subview)
if subview.point(inside: converted, with: event) {
return true
}
}
return false
}
}
final class CubeAnimationView: UIView {
private let cubeSize: CGFloat
private var perspective: CGFloat = 400.0
private let stickerSize: CGFloat
private let stickerGap: CGFloat
private let camera = UIView()
private let cubeContainer = Transform3DView()
private var faces: [UIView] = []
private var faceOccupants: [Int: UIView] = [:]
let stickerContainer = PassthroughView()
private var stickers: [UIView] = []
private var isRunning = false
private var displayLink: SharedDisplayLinkDriver.Link?
private var lastTimestamp: CFTimeInterval = 0
private var warpDisplayLink: SharedDisplayLinkDriver.Link?
private weak var warpView: UIView?
private var warpStartQuad: Quad?
private var warpEndQuad: Quad?
private var warpDuration: TimeInterval = 0
private var warpDynamicTarget: (() -> Quad)?
private var warpCompletion: (() -> Void)?
private var warpStartTimestamp: CFTimeInterval = 0
private var warpLastProgress: CGFloat = 0
private var warpCurrentQuad: Quad?
private var warpHasCompleted = false
private var warpSnapshot: UIView?
private var rotation = SIMD3<Float>(repeating: 0)
private var angularVelocity = SIMD3<Float>(repeating: 0)
private let dampingPerSecond: Float = 0.66
private let finishSpringX: Float = 28.0
private let finishSpringY: Float = 18.0
private let finishDampingX: Float = 2.0 * sqrt(28.0)
private let finishDampingY: Float = 2.0 * sqrt(18.0)
private let finishWobbleAmplitudeZ: Float = 10.0 * .pi / 180.0
private let finishWobbleCycles: Float = 1.0
private let finishWobbleDampingExponent: Float = 0.6
private let finishSuccessScale: Float = 1.3
private let finishSuccessScaleTriggerAngle: Float = 0.4 * .pi
private let finishApproachTriggerAngle: Float = 1.5 * .pi
private let baseImpulseStrength: Float = 4.0
private let impactNudgeDistance: CGFloat = 20.0
private let impactNudgeEmphasis: CGFloat = 28.0
private var isFinishingX = false
private var isFinishingY = false
private var finishTargetX: Float = 0.0
private var finishTargetY: Float = 0.0
private var finishDirectionY: Float = 1.0
private var finishRotationY: Float = 0.0
private var finishTargetYUnwrapped: Float = 0.0
private var finishRemainingYStart: Float = 0.0
private var finishDelayTimerX: Timer?
private var finishDelayTimerY: Timer?
private var cubeScale: Float = 1.0
private var hasFiredFinishApproach = false
var isSuccess = false
var onStickerLaunch: (() -> Void)?
var onFinishApproach: ((Bool, Bool) -> Void)?
private let defaultStickOrder: [Int] = [0, 5, 4, 3]
private let sequenceStickOrders: [String: [Int]] = [
"0": [0],
"0,1": [0, 5],
"0,2": [0, 5],
"0,3": [0, 5],
"0,1,2": [0, 5, 4],
"0,1,3": [0, 5, 2],
"0,2,3": [0, 5, 1],
"0,1,2,3": [0, 5, 4, 3]
]
private var activeStickOrder: [Int] = []
init(cubeSize: CGFloat = 110.0, stickerSize: CGFloat = 76.0, stickerGap: CGFloat = 30.0) {
self.cubeSize = cubeSize
self.stickerSize = stickerSize
self.stickerGap = stickerGap
super.init(frame: .zero)
self.activeStickOrder = self.defaultStickOrder
self.camera.backgroundColor = .clear
self.camera.clipsToBounds = false
self.addSubview(self.camera)
self.cubeContainer.backgroundColor = .clear
self.cubeContainer.clipsToBounds = false
self.camera.addSubview(self.cubeContainer)
var p = CATransform3DIdentity
p.m34 = -1.0 / self.perspective
self.camera.layer.sublayerTransform = p
self.stickerContainer.layer.sublayerTransform = p
self.stickerContainer.backgroundColor = .clear
self.stickerContainer.clipsToBounds = false
self.addSubview(self.stickerContainer)
#if DEBUG
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
self.camera.addGestureRecognizer(pan)
#endif
}
required init?(coder: NSCoder) {
preconditionFailure()
}
override func layoutSubviews() {
super.layoutSubviews()
self.camera.bounds = CGRect(x: 0, y: 0, width: self.cubeSize, height: self.cubeSize)
self.camera.center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
self.cubeContainer.frame = self.camera.bounds
self.stickerContainer.frame = self.bounds
self.layoutStickers()
self.layoutFaces()
self.applyCubeRotation()
}
func setStickers(_ views: [UIView]) {
self.stickers = views
for view in views {
view.layer.anchorPoint = .zero
view.isUserInteractionEnabled = true
if view.superview !== self.stickerContainer {
self.stickerContainer.addSubview(view)
}
}
self.layoutStickers()
}
func setSticker(_ sticker: UIView?, face index: Int, mirror: Bool, animated: Bool = false) {
guard self.faces.indices.contains(index) else {
return
}
if let existing = self.faceOccupants[index] {
existing.removeFromSuperview()
self.faceOccupants[index] = nil
}
guard let sticker else {
return
}
if let priorIndex = self.faceOccupants.first(where: { $0.value === sticker })?.key {
self.faceOccupants[priorIndex] = nil
}
if animated, let stickerSuperview = sticker.superview, let snapshotView = sticker.snapshotView(afterScreenUpdates: false) {
stickerSuperview.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
snapshotView.removeFromSuperview()
})
}
sticker.removeFromSuperview()
let targetFace = self.faces[index]
targetFace.addSubview(sticker)
self.faceOccupants[index] = sticker
sticker.layer.removeAllAnimations()
sticker.transform = .identity
sticker.layer.transform = CATransform3DIdentity
sticker.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
sticker.layer.isDoubleSided = false
sticker.clipsToBounds = false
sticker.isUserInteractionEnabled = false
let faceStickerSize = self.cubeSize
sticker.bounds = CGRect(x: 0, y: 0, width: faceStickerSize, height: faceStickerSize)
sticker.center = CGPoint(x: self.cubeSize / 2, y: self.cubeSize / 2)
var snappedAngle: CGFloat = 0.0
if mirror {
snappedAngle += .pi
}
sticker.transform = CGAffineTransform(rotationAngle: snappedAngle)
if animated {
sticker.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
func startStickerSequence(indices: [Int]? = nil) {
guard !self.isRunning else {
return
}
guard self.stickers.contains(where: { $0.superview === self.stickerContainer }) else {
return
}
self.isRunning = true
let sequence: [Int]
if let indices, !indices.isEmpty {
var seen = Set<Int>()
var result: [Int] = []
for index in indices where self.stickers.indices.contains(index) {
if seen.insert(index).inserted {
result.append(index)
}
}
sequence = result
} else {
sequence = Array(self.stickers.indices)
}
var stickOrder: [Int]
let key = sequence.map(String.init).joined(separator: ",")
if let order = self.sequenceStickOrders[key] {
stickOrder = order
} else {
stickOrder = Array(self.defaultStickOrder.prefix(sequence.count))
}
self.activeStickOrder = stickOrder
self.scheduleStickerSequence(from: 0, indices: sequence)
}
func resetAll() {
self.isRunning = false
self.resetStickers()
self.resetCube()
self.activeStickOrder = self.defaultStickOrder
}
func setFaces(_ views: [UIView]) {
guard views.count == 6 else {
return
}
self.faces.forEach { $0.removeFromSuperview() }
self.faces = views
for face in views {
face.layer.isDoubleSided = false
self.cubeContainer.addSubview(face)
}
self.layoutFaces()
}
private func layoutFaces() {
guard self.faces.count == 6 else {
return
}
let half = self.cubeSize / 2
for face in self.faces {
face.bounds = CGRect(x: 0, y: 0, width: self.cubeSize, height: self.cubeSize)
face.center = CGPoint(x: self.cubeSize / 2, y: self.cubeSize / 2)
}
func faceTransform(rx: CGFloat, ry: CGFloat) -> CATransform3D {
var m = CATransform3DIdentity
m = CATransform3DRotate(m, rx, 1, 0, 0)
m = CATransform3DRotate(m, ry, 0, 1, 0)
m = CATransform3DTranslate(m, 0, 0, half)
return m
}
self.faces[0].layer.transform = faceTransform(rx: 0, ry: 0)
self.faces[1].layer.transform = faceTransform(rx: 0, ry: .pi / 2)
self.faces[2].layer.transform = faceTransform(rx: 0, ry: .pi)
self.faces[3].layer.transform = faceTransform(rx: 0, ry: -.pi / 2)
self.faces[4].layer.transform = faceTransform(rx: -.pi / 2, ry: 0)
self.faces[5].layer.transform = faceTransform(rx: .pi / 2, ry: 0)
}
private func animateWarp(for view: UIView, from startQuad: Quad, to targetQuad: Quad, duration: TimeInterval, dynamicTarget: (() -> Quad)? = nil, completion: @escaping () -> Void) {
self.cancelWarp()
self.warpView = view
self.warpStartQuad = startQuad
self.warpEndQuad = targetQuad
self.warpDuration = duration
self.warpDynamicTarget = dynamicTarget
self.warpCompletion = completion
self.warpStartTimestamp = 0
self.warpLastProgress = 0
self.warpHasCompleted = false
self.warpCurrentQuad = startQuad
startQuad.apply(to: view)
let link = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max) { [weak self] _ in
self?.stepWarp()
}
link.isPaused = false
self.warpDisplayLink = link
}
private func stepWarp() {
guard let view = self.warpView, let currentQuad = self.warpCurrentQuad, let endQuad = self.warpEndQuad else {
self.finishWarp()
return
}
if self.warpStartTimestamp == 0 {
self.warpStartTimestamp = CACurrentMediaTime()
}
let elapsed = CACurrentMediaTime() - self.warpStartTimestamp
let progress = self.warpDuration > 0 ? min(1.0, elapsed / self.warpDuration) : 1.0
let t = CGFloat(progress)
let eased = t * t * (3 - 2 * t)
let target = self.warpDynamicTarget?() ?? endQuad
let delta = eased - self.warpLastProgress
let remaining = max(1 - self.warpLastProgress, 0.0001)
let weight = max(0, min(1, delta / remaining))
let nextQuad = currentQuad.interpolated(to: target, t: weight)
nextQuad.apply(to: view)
self.warpCurrentQuad = nextQuad
self.warpLastProgress = eased
if progress >= 1.0 {
self.finishWarp()
}
}
private func cancelWarp() {
self.warpHasCompleted = true
self.warpDisplayLink?.invalidate()
self.warpDisplayLink = nil
self.warpCompletion = nil
self.clearWarpState()
}
private func finishWarp() {
guard !self.warpHasCompleted else { return }
self.warpHasCompleted = true
self.warpDisplayLink?.invalidate()
self.warpDisplayLink = nil
self.warpCompletion?()
self.warpCompletion = nil
self.clearWarpState()
}
private func clearWarpState() {
self.warpView = nil
self.warpStartQuad = nil
self.warpEndQuad = nil
self.warpDynamicTarget = nil
self.warpStartTimestamp = 0
self.warpLastProgress = 0
self.warpCurrentQuad = nil
}
private func projectedQuad(for face: UIView) -> ProjectedFace {
let bounds = face.bounds
func project(_ p: CGPoint) -> CGPoint {
let inRoot = face.layer.convert(p, to: self.layer)
return self.stickerContainer.layer.convert(inRoot, from: self.layer)
}
var topLeft = project(CGPoint(x: bounds.minX, y: bounds.minY))
var topRight = project(CGPoint(x: bounds.maxX, y: bounds.minY))
var bottomLeft = project(CGPoint(x: bounds.minX, y: bounds.maxY))
var bottomRight = project(CGPoint(x: bounds.maxX, y: bounds.maxY))
func center(_ a: CGPoint, _ b: CGPoint) -> CGPoint {
CGPoint(x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5)
}
func normalized(_ v: CGPoint) -> CGPoint? {
let len = hypot(v.x, v.y)
guard len > 1e-5 else { return nil }
return CGPoint(x: v.x / len, y: v.y / len)
}
func dot(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
a.x * b.x + a.y * b.y
}
let screenUp = CGPoint(x: 0, y: -1)
let screenRight = CGPoint(x: 1, y: 0)
if let up = normalized(CGPoint(
x: center(topLeft, topRight).x - center(bottomLeft, bottomRight).x,
y: center(topLeft, topRight).y - center(bottomLeft, bottomRight).y
)), dot(up, screenUp) < 0 {
swap(&topLeft, &bottomLeft)
swap(&topRight, &bottomRight)
}
let faceOrigin = project(.zero)
let faceX = project(CGPoint(x: 1, y: 0))
if let right = normalized(CGPoint(
x: center(topRight, bottomRight).x - center(topLeft, bottomLeft).x,
y: center(topRight, bottomRight).y - center(topLeft, bottomLeft).y
)), dot(right, screenRight) < 0 {
swap(&topLeft, &topRight)
swap(&bottomLeft, &bottomRight)
}
let quad = Quad(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
let desiredTopVector = CGPoint(x: quad.topRight.x - quad.topLeft.x, y: quad.topRight.y - quad.topLeft.y)
let baseTopVector = CGPoint(x: faceX.x - faceOrigin.x, y: faceX.y - faceOrigin.y)
let desiredAngle = atan2(desiredTopVector.y, desiredTopVector.x)
let baseAngle = atan2(baseTopVector.y, baseTopVector.x)
let rotation = normalizeAngle(desiredAngle - baseAngle)
return ProjectedFace(quad: quad, rotation: rotation)
}
private func layoutStickers() {
guard !self.stickers.isEmpty else {
return
}
let cubeCenterInSticker = self.stickerContainer.convert(self.camera.center, from: self)
let r = self.cubeSize / 2 + self.stickerGap + self.stickerSize / 2
let scale = self.stickerSize / self.cubeSize
let positions = [
CGPoint(x: cubeCenterInSticker.x - r, y: cubeCenterInSticker.y - r * 0.4),
CGPoint(x: cubeCenterInSticker.x + r, y: cubeCenterInSticker.y - r * 0.4),
CGPoint(x: cubeCenterInSticker.x - r, y: cubeCenterInSticker.y + r * 0.4),
CGPoint(x: cubeCenterInSticker.x + r, y: cubeCenterInSticker.y + r * 0.4)
]
for (i, view) in self.stickers.enumerated() {
if view.superview !== self.stickerContainer {
continue
}
view.bounds = CGRect(x: 0, y: 0, width: self.cubeSize, height: self.cubeSize)
view.transform = CGAffineTransform(scaleX: scale, y: scale)
view.center = CGPoint(x: positions[i].x - self.stickerSize * 0.5, y: positions[i].y - self.stickerSize * 0.5)
}
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: self.camera)
switch gesture.state {
case .changed:
let delta = CGPoint(x: translation.x, y: translation.y)
self.rotation.y += Float(delta.x) * 0.018
self.rotation.x += Float(-delta.y) * 0.018
self.rotation = normalizedRotation(self.rotation)
self.applyCubeRotation()
gesture.setTranslation(.zero, in: self.camera)
default:
break
}
}
func launchStickerView(_ sticker: UIView, emphasized: Bool, willFinish: Bool = false) {
guard sticker.superview === self.stickerContainer else {
return
}
var number = 0
if self.faceOccupants.count < self.activeStickOrder.count {
number = self.activeStickOrder[self.faceOccupants.count]
}
let faceIndex = number
guard self.faces.count > faceIndex else { return }
let targetFace = self.faces[faceIndex]
let startCenterInSticker = sticker.center
let cubeCenterInSticker = self.stickerContainer.convert(self.camera.center, from: self)
sticker.isUserInteractionEnabled = false
sticker.layer.isDoubleSided = false
let faceStickerSize = self.cubeSize
let duration: TimeInterval = 0.2
let startQuad = Quad(rect: sticker.frame)
let animationView: UIView
if let snapshot = sticker.snapshotView(afterScreenUpdates: false) {
self.warpSnapshot?.removeFromSuperview()
self.warpSnapshot = snapshot
snapshot.bounds = sticker.bounds
snapshot.center = sticker.center
snapshot.layer.anchorPoint = sticker.layer.anchorPoint
snapshot.layer.transform = sticker.layer.transform
snapshot.layer.isDoubleSided = sticker.layer.isDoubleSided
snapshot.isUserInteractionEnabled = false
self.stickerContainer.addSubview(snapshot)
sticker.isHidden = true
animationView = snapshot
} else {
animationView = sticker
}
sticker.transform = .identity
let projectedFace = self.projectedQuad(for: targetFace)
let targetQuad = projectedFace.quad
let dynamicTarget: () -> Quad = { [weak self, weak targetFace] in
guard let self, let face = targetFace else {
return targetQuad
}
return self.projectedQuad(for: face).quad
}
self.animateWarp(for: animationView, from: startQuad, to: targetQuad, duration: duration, dynamicTarget: dynamicTarget) { [weak self, weak sticker, weak targetFace, weak animationView] in
guard let self, let sticker, let targetFace else {
return
}
self.onStickerLaunch?()
if let animationView, animationView !== sticker {
animationView.removeFromSuperview()
self.warpSnapshot = nil
sticker.isHidden = false
}
sticker.removeFromSuperview()
targetFace.addSubview(sticker)
self.faceOccupants[faceIndex] = sticker
sticker.bounds = CGRect(x: 0, y: 0, width: faceStickerSize, height: faceStickerSize)
sticker.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
sticker.center = CGPoint(x: self.cubeSize / 2, y: self.cubeSize / 2)
sticker.layer.transform = CATransform3DIdentity
let finalProjection = self.projectedQuad(for: targetFace)
let snappedAngle = snappedRightAngle(finalProjection.rotation)
sticker.transform = CGAffineTransform(rotationAngle: snappedAngle)
let delta = SIMD2<Float>(Float(cubeCenterInSticker.x - startCenterInSticker.x), Float(cubeCenterInSticker.y - startCenterInSticker.y))
let direction = normalize2(delta)
self.applyImpulse(direction: direction, emphasized: emphasized, replace: true)
self.applyImpactSpring(direction: direction, emphasized: emphasized)
if willFinish {
self.startFinishingAnimation()
}
self.startSpinLoopIfNeeded()
}
}
private func resetStickers() {
self.cancelWarp()
self.warpSnapshot?.removeFromSuperview()
self.warpSnapshot = nil
for sticker in self.stickers {
sticker.layer.removeAllAnimations()
sticker.transform = .identity
sticker.layer.transform = CATransform3DIdentity
sticker.layer.anchorPoint = .zero
sticker.layer.isDoubleSided = true
sticker.clipsToBounds = false
sticker.isUserInteractionEnabled = true
sticker.removeFromSuperview()
self.stickerContainer.addSubview(sticker)
}
self.faceOccupants.removeAll()
self.layoutStickers()
}
private func resetCube() {
self.displayLink?.invalidate()
self.displayLink = nil
self.angularVelocity = .zero
self.lastTimestamp = 0
self.isFinishingX = false
self.isFinishingY = false
self.finishDelayTimerX?.invalidate()
self.finishDelayTimerX = nil
self.finishDelayTimerY?.invalidate()
self.finishDelayTimerY = nil
self.cubeScale = 1.0
self.hasFiredFinishApproach = false
self.rotation = SIMD3<Float>(repeating: 0)
self.cubeScale = 1.0
self.applyCubeRotation()
}
private func scheduleStickerSequence(from index: Int, indices: [Int]) {
guard self.isRunning else {
return
}
guard index < indices.count else {
self.isRunning = false
return
}
let delay: TimeInterval = index == 0 ? 0.0 : 1.0
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else {
return
}
guard self.isRunning else {
return
}
let stickerIndex = indices[index]
if self.stickers.indices.contains(stickerIndex) {
let isLast = index == indices.count - 1
self.launchStickerView(self.stickers[stickerIndex], emphasized: isLast, willFinish: isLast)
}
self.scheduleStickerSequence(from: index + 1, indices: indices)
}
}
private func applyImpulse(direction: SIMD2<Float>, emphasized: Bool, replace: Bool) {
var xStrength = self.baseImpulseStrength
var yStrength = self.baseImpulseStrength
if emphasized {
xStrength *= 10.0
yStrength *= 4.0
}
let impulseX: Float = -direction.y * xStrength
let impulseY: Float = direction.x * yStrength
let impulseZ: Float = 0.0
if replace {
self.angularVelocity = SIMD3<Float>(impulseX, impulseY, impulseZ)
} else {
self.angularVelocity += SIMD3<Float>(impulseX, impulseY, impulseZ)
}
}
private func applyImpactSpring(direction: SIMD2<Float>, emphasized: Bool) {
guard simd_length(direction) > 0.0001 else {
return
}
let distance = emphasized ? self.impactNudgeEmphasis : self.impactNudgeDistance
let offsetX = CGFloat(direction.x) * distance
let offsetY = CGFloat(direction.y) * distance
let currentTransform = self.camera.layer.presentation()?.affineTransform() ?? self.camera.transform
self.camera.layer.removeAllAnimations()
let impactTransform = currentTransform.translatedBy(x: offsetX, y: offsetY)
UIView.animate(withDuration: 0.08, delay: 0.0, options: [.curveEaseOut, .beginFromCurrentState]) {
self.camera.transform = impactTransform
} completion: { _ in
UIView.animate(withDuration: 0.55, delay: 0, usingSpringWithDamping: 0.72, initialSpringVelocity: 0.2, options: .beginFromCurrentState) {
self.camera.transform = .identity
}
}
}
private func startSpinLoopIfNeeded() {
if self.displayLink == nil {
let link = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max) { [weak self] _ in
self?.tick()
}
link.isPaused = false
self.displayLink = link
self.lastTimestamp = 0.0
}
}
private func tick() {
let ts = CACurrentMediaTime()
if self.lastTimestamp == 0 { self.lastTimestamp = ts; return }
let dt = Float(ts - self.lastTimestamp)
self.lastTimestamp = ts
self.rotation += self.angularVelocity * dt
if self.isFinishingX {
let delta = shortestAngleDelta(from: self.rotation.x, to: self.finishTargetX)
let accel = self.finishSpringX * delta - self.finishDampingX * self.angularVelocity.x
self.angularVelocity.x += accel * dt
if abs(delta) < 0.0006 && abs(self.angularVelocity.x) < 0.001 {
self.rotation.x = self.finishTargetX
self.angularVelocity.x = 0.0
self.isFinishingX = false
}
}
if self.isFinishingY {
self.finishRotationY += self.angularVelocity.y * dt
let remaining = self.finishTargetYUnwrapped - self.finishRotationY
let accel = self.finishSpringY * remaining - self.finishDampingY * self.angularVelocity.y
self.angularVelocity.y += accel * dt
self.rotation.y = normalizeAngle(self.finishRotationY)
let total = max(abs(self.finishRemainingYStart), 0.0001)
let progress = min(max(1.0 - abs(remaining) / total, 0.0), 1.0)
let damping = pow(1.0 - progress, self.finishWobbleDampingExponent)
let phase = 2.0 * Float.pi * self.finishWobbleCycles * progress
self.rotation.z = self.finishWobbleAmplitudeZ * sin(phase) * damping
let absRemaining = abs(remaining)
if !self.hasFiredFinishApproach && absRemaining <= self.finishApproachTriggerAngle {
self.hasFiredFinishApproach = true
let upsideDown = abs(shortestAngleDelta(from: self.rotation.x, to: Float.pi)) < (Float.pi / 2)
let isClockwise = self.finishDirectionY > 0
self.onFinishApproach?(upsideDown, isClockwise)
}
if self.isSuccess, absRemaining <= self.finishSuccessScaleTriggerAngle {
let raw = (self.finishSuccessScaleTriggerAngle - absRemaining) / self.finishSuccessScaleTriggerAngle
let eased = raw * raw * (3 - 2 * raw)
self.cubeScale = 1.0 + (self.finishSuccessScale - 1.0) * eased
} else if !self.isSuccess {
self.cubeScale = 1.0
}
if abs(remaining) < 0.0008 && abs(self.angularVelocity.y) < 0.0015 {
self.finishRotationY = self.finishTargetYUnwrapped
self.rotation.y = self.finishTargetY
self.angularVelocity.y = 0.0
self.isFinishingY = false
self.rotation.z = 0.0
self.angularVelocity.z = 0.0
}
} else if self.rotation.z != 0 {
self.rotation.z = 0.0
}
self.rotation = normalizedRotation(self.rotation)
let damp = pow(self.dampingPerSecond, dt)
self.angularVelocity *= damp
self.applyCubeRotation()
}
private func startFinishingAnimation() {
self.finishDelayTimerX?.invalidate()
self.finishDelayTimerX = Timer.scheduledTimer(withTimeInterval: 0.75, repeats: false) { [weak self] _ in
self?.beginFinishingX()
}
}
private func beginFinishingX() {
let deltaToZero = abs(shortestAngleDelta(from: self.rotation.x, to: 0))
let deltaToPi = abs(shortestAngleDelta(from: self.rotation.x, to: Float.pi))
self.finishTargetX = deltaToZero <= deltaToPi ? 0 : Float.pi
self.finishTargetY = self.finishTargetX == 0 ? 0 : Float.pi
self.isFinishingX = true
self.finishDelayTimerY?.invalidate()
self.finishDelayTimerY = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
self?.beginFinishingY()
}
}
private func beginFinishingY() {
self.finishRotationY = self.rotation.y
let directionY = nonZeroSign(self.angularVelocity.y, fallback: 1)
self.finishDirectionY = directionY
let startMod = normalizeAnglePositive(self.finishRotationY)
let targetMod = normalizeAnglePositive(self.finishTargetY)
let baseDelta: Float
if directionY >= 0 {
baseDelta = targetMod >= startMod ? targetMod - startMod : (Float.pi * 2) - (startMod - targetMod)
} else {
baseDelta = startMod >= targetMod ? startMod - targetMod : (Float.pi * 2) - (targetMod - startMod)
}
var delta = baseDelta
if delta < Float.pi {
delta += Float.pi * 2
}
self.finishTargetYUnwrapped = self.finishRotationY + directionY * delta
self.finishRemainingYStart = self.finishTargetYUnwrapped - self.finishRotationY
self.isFinishingY = true
self.hasFiredFinishApproach = false
}
private func applyCubeRotation() {
var m = CATransform3DIdentity
m = CATransform3DRotate(m, CGFloat(self.rotation.x), 1, 0, 0)
m = CATransform3DRotate(m, CGFloat(self.rotation.y), 0, 1, 0)
m = CATransform3DRotate(m, CGFloat(self.rotation.z), 0, 0, 1)
m = CATransform3DScale(m, CGFloat(self.cubeScale), CGFloat(self.cubeScale), 1)
self.cubeContainer.layer.transform = m
}
}
@@ -0,0 +1,193 @@
import UIKit
import simd
func normalize2(_ v: SIMD2<Float>) -> SIMD2<Float> {
let l = simd_length(v)
return l > 1e-5 ? v / l : SIMD2<Float>(0, 0)
}
func normalizedRotation(_ r: SIMD3<Float>) -> SIMD3<Float> {
SIMD3<Float>(normalizeAngle(r.x), normalizeAngle(r.y), normalizeAngle(r.z))
}
struct ProjectedFace {
let quad: Quad
let rotation: CGFloat
}
struct Quad {
var topLeft: CGPoint
var topRight: CGPoint
var bottomLeft: CGPoint
var bottomRight: CGPoint
init(topLeft: CGPoint, topRight: CGPoint, bottomLeft: CGPoint, bottomRight: CGPoint) {
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
}
init(rect: CGRect) {
self.init(
topLeft: rect.origin,
topRight: CGPoint(x: rect.maxX, y: rect.minY),
bottomLeft: CGPoint(x: rect.minX, y: rect.maxY),
bottomRight: CGPoint(x: rect.maxX, y: rect.maxY)
)
}
func boundingBox() -> CGRect {
let xs = [topLeft.x, topRight.x, bottomLeft.x, bottomRight.x]
let ys = [topLeft.y, topRight.y, bottomLeft.y, bottomRight.y]
guard let minX = xs.min(), let maxX = xs.max(), let minY = ys.min(), let maxY = ys.max() else {
return .zero
}
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
}
func offsetting(dx: CGFloat, dy: CGFloat) -> Quad {
return Quad(
topLeft: CGPoint(x: topLeft.x + dx, y: topLeft.y + dy),
topRight: CGPoint(x: topRight.x + dx, y: topRight.y + dy),
bottomLeft: CGPoint(x: bottomLeft.x + dx, y: bottomLeft.y + dy),
bottomRight: CGPoint(x: bottomRight.x + dx, y: bottomRight.y + dy)
)
}
func interpolated(to other: Quad, t: CGFloat) -> Quad {
return Quad(
topLeft: lerp(topLeft, other.topLeft, t),
topRight: lerp(topRight, other.topRight, t),
bottomLeft: lerp(bottomLeft, other.bottomLeft, t),
bottomRight: lerp(bottomRight, other.bottomRight, t)
)
}
func apply(to view: UIView) {
let bounds = boundingBox()
let localQuad = offsetting(dx: -bounds.origin.x, dy: -bounds.origin.y)
CATransaction.begin()
CATransaction.setDisableActions(true)
view.frame = bounds
let transform = rectToQuad(rect: view.bounds, quad: localQuad)
view.layer.transform = transform
CATransaction.commit()
}
}
func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat {
return a + (b - a) * t
}
func lerp(_ a: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint {
return CGPoint(x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t))
}
func normalizeAngle(_ angle: CGFloat) -> CGFloat {
var result = angle
let twoPi = CGFloat.pi * 2
while result > CGFloat.pi {
result -= twoPi
}
while result <= -CGFloat.pi {
result += twoPi
}
return result
}
func normalizeAngle(_ angle: Float) -> Float {
var result = angle
let twoPi = Float.pi * 2
while result > Float.pi {
result -= twoPi
}
while result <= -Float.pi {
result += twoPi
}
return result
}
func normalizeAnglePositive(_ angle: Float) -> Float {
var result = angle
let twoPi = Float.pi * 2
while result < 0 { result += twoPi }
while result >= twoPi { result -= twoPi }
return result
}
func shortestAngleDelta(from: Float, to: Float) -> Float {
return normalizeAngle(to - from)
}
func nonZeroSign(_ value: Float, fallback: Float) -> Float {
if value > 0 { return 1 }
if value < 0 { return -1 }
return fallback
}
func snappedRightAngle(_ angle: CGFloat) -> CGFloat {
let quarter = CGFloat.pi / 2
let normalized = normalizeAngle(angle)
let step = round(normalized / quarter)
return step * quarter
}
func rectToQuad(rect: CGRect, quad: Quad) -> CATransform3D {
let x1a = quad.topLeft.x
let y1a = quad.topLeft.y
let x2a = quad.topRight.x
let y2a = quad.topRight.y
let x3a = quad.bottomLeft.x
let y3a = quad.bottomLeft.y
let x4a = quad.bottomRight.x
let y4a = quad.bottomRight.y
let X = rect.origin.x
let Y = rect.origin.y
let W = rect.size.width
let H = rect.size.height
let y21 = y2a - y1a
let y32 = y3a - y2a
let y43 = y4a - y3a
let y14 = y1a - y4a
let y31 = y3a - y1a
let y42 = y4a - y2a
let a = -H * (x2a * x3a * y14 + x2a * x4a * y31 - x1a * x4a * y32 + x1a * x3a * y42)
let b = W * (x2a * x3a * y14 + x3a * x4a * y21 + x1a * x4a * y32 + x1a * x2a * y43)
let c = H * X * (x2a * x3a * y14 + x2a * x4a * y31 - x1a * x4a * y32 + x1a * x3a * y42)
- H * W * x1a * (x4a * y32 - x3a * y42 + x2a * y43)
- W * Y * (x2a * x3a * y14 + x3a * x4a * y21 + x1a * x4a * y32 + x1a * x2a * y43)
let d = H * (-x4a * y21 * y3a + x2a * y1a * y43 - x1a * y2a * y43 - x3a * y1a * y4a + x3a * y2a * y4a)
let e = W * (x4a * y2a * y31 - x3a * y1a * y42 - x2a * y31 * y4a + x1a * y3a * y42)
let f = -(
W * (x4a * (Y * y2a * y31 + H * y1a * y32)
- x3a * (H + Y) * y1a * y42
+ H * x2a * y1a * y43
+ x2a * Y * (y1a - y3a) * y4a
+ x1a * Y * y3a * (-y2a + y4a))
- H * X * (x4a * y21 * y3a - x2a * y1a * y43 + x3a * (y1a - y2a) * y4a + x1a * y2a * (-y3a + y4a))
)
let g = H * (x3a * y21 - x4a * y21 + (-x1a + x2a) * y43)
let h = W * (-x2a * y31 + x4a * y31 + (x1a - x3a) * y42)
var i = W * Y * (x2a * y31 - x4a * y31 - x1a * y42 + x3a * y42)
+ H * (X * (-(x3a * y21) + x4a * y21 + x1a * y43 - x2a * y43)
+ W * (-(x3a * y2a) + x4a * y2a + x2a * y3a - x4a * y3a - x2a * y4a + x3a * y4a))
let epsilon: CGFloat = 0.0001
if abs(i) < epsilon {
i = i >= 0 ? epsilon : -epsilon
}
return CATransform3D(
m11: a / i, m12: d / i, m13: 0, m14: g / i,
m21: b / i, m22: e / i, m23: 0, m24: h / i,
m31: 0, m32: 0, m33: 1, m34: 0,
m41: c / i, m42: f / i, m43: 0, m44: 1
)
}
@@ -0,0 +1,295 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import AccountContext
import MultilineTextComponent
import AnimatedTextComponent
final class DialIndicatorComponent: Component {
let content: AnyComponentWithIdentity<Empty>
let backgroundColor: UIColor
let foregroundColor: UIColor
let diameter: CGFloat
let contentSize: CGSize?
let lineWidth: CGFloat
let fontSize: CGFloat
let progress: CGFloat
let value: Int
let suffix: String
let isVisible: Bool
let isFlipped: Bool
public init(
content: AnyComponentWithIdentity<Empty>,
backgroundColor: UIColor,
foregroundColor: UIColor,
diameter: CGFloat,
contentSize: CGSize? = nil,
lineWidth: CGFloat,
fontSize: CGFloat,
progress: CGFloat,
value: Int,
suffix: String,
isVisible: Bool = true,
isFlipped: Bool = false
) {
self.content = content
self.backgroundColor = backgroundColor
self.foregroundColor = foregroundColor
self.diameter = diameter
self.contentSize = contentSize
self.lineWidth = lineWidth
self.fontSize = fontSize
self.progress = progress
self.value = value
self.suffix = suffix
self.isVisible = isVisible
self.isFlipped = isFlipped
}
public static func ==(lhs: DialIndicatorComponent, rhs: DialIndicatorComponent) -> Bool {
if lhs.content != rhs.content {
return false
}
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.foregroundColor != rhs.foregroundColor {
return false
}
if lhs.diameter != rhs.diameter {
return false
}
if lhs.contentSize != rhs.contentSize {
return false
}
if lhs.lineWidth != rhs.lineWidth {
return false
}
if lhs.fontSize != rhs.fontSize {
return false
}
if lhs.progress != rhs.progress {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.suffix != rhs.suffix {
return false
}
if lhs.isVisible != rhs.isVisible {
return false
}
if lhs.isFlipped != rhs.isFlipped {
return false
}
return true
}
public final class View: UIView {
private let containerView = UIView()
private let backgroundLayer = SimpleShapeLayer()
private let foregroundLayer = SimpleShapeLayer()
private var content = ComponentView<Empty>()
private let label = ComponentView<Empty>()
private var component: DialIndicatorComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundLayer.lineCap = .round
self.foregroundLayer.lineCap = .round
self.addSubview(self.containerView)
self.containerView.layer.addSublayer(self.backgroundLayer)
self.containerView.layer.addSublayer(self.foregroundLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: DialIndicatorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
let pathSize = CGSize(width: component.diameter, height: component.diameter)
let pathFrame = CGRect(origin: .zero, size: pathSize).insetBy(dx: component.lineWidth * 0.5, dy: component.lineWidth * 0.5)
let strokeStart: CGFloat = 0.125
let strokeEnd: CGFloat = 1.0 - strokeStart
self.backgroundLayer.lineWidth = component.lineWidth
self.backgroundLayer.strokeColor = component.backgroundColor.cgColor
self.backgroundLayer.fillColor = UIColor.clear.cgColor
self.backgroundLayer.path = CGPath(ellipseIn: pathFrame, transform: nil)
self.backgroundLayer.transform = CATransform3DMakeRotation(.pi / 2.0, 0.0, 0.0, 1.0)
self.backgroundLayer.strokeStart = strokeStart
self.backgroundLayer.strokeEnd = strokeEnd
self.backgroundLayer.frame = CGRect(origin: .zero, size: pathSize)
self.foregroundLayer.lineWidth = component.lineWidth
self.foregroundLayer.strokeColor = component.foregroundColor.cgColor
self.foregroundLayer.fillColor = UIColor.clear.cgColor
self.foregroundLayer.path = CGPath(ellipseIn: pathFrame, transform: nil)
self.foregroundLayer.transform = CATransform3DMakeRotation(.pi / 2.0, 0.0, 0.0, 1.0)
self.foregroundLayer.strokeStart = strokeStart
transition.setShapeLayerStrokeEnd(layer: self.foregroundLayer, strokeEnd: strokeStart + (strokeEnd - strokeStart) * component.progress)
self.foregroundLayer.frame = CGRect(origin: .zero, size: pathSize)
if previousComponent?.content.id != component.content.id {
if let contentView = self.content.view {
if transition.animation.isImmediate {
contentView.removeFromSuperview()
} else {
transition.setScale(view: contentView, scale: 0.01)
transition.setAlpha(view: contentView, alpha: 0.0, completion: { _ in
contentView.removeFromSuperview()
})
}
}
self.content = ComponentView()
}
let contentSize = component.contentSize ?? CGSize(width: component.diameter - 16.0, height: component.diameter - 16.0)
let contentFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((pathSize.width - contentSize.width) / 2.0), y: floorToScreenPixels((pathSize.height - contentSize.height) / 2.0)), size: contentSize)
let _ = self.content.update(
transition: .immediate,
component: component.content.component,
environment: {},
containerSize: contentFrame.size
)
if let contentView = self.content.view {
if contentView.superview == nil {
self.containerView.addSubview(contentView)
if !transition.animation.isImmediate {
transition.animateScale(view: contentView, from: 0.01, to: 1.0)
transition.animateAlpha(view: contentView, from: 0.0, to: 1.0)
}
}
contentView.frame = contentFrame
}
var labelItems: [AnimatedTextComponent.Item] = [
AnimatedTextComponent.Item(id: "percent", content: .number(component.value, minDigits: 1))
]
if !component.suffix.isEmpty {
labelItems.append(AnimatedTextComponent.Item(id: "suffix", content: .text(component.suffix)))
}
let labelSize = self.label.update(
transition: transition,
component: AnyComponent(
AnimatedTextComponent(
font: Font.semibold(component.fontSize),
color: component.foregroundColor,
items: labelItems
)
),
environment: {},
containerSize: availableSize
)
if let labelView = self.label.view {
if labelView.superview == nil {
self.containerView.addSubview(labelView)
}
transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((pathSize.width - labelSize.width) / 2.0) + 1.0 - UIScreenPixel, y: pathSize.height - labelSize.height + 2.0 - UIScreenPixel), size: labelSize))
}
transition.setAlpha(view: self.containerView, alpha: component.isVisible ? 1.0 : 0.0)
transition.setBlur(layer: self.containerView.layer, radius: component.isVisible ? 0.0 : 10.0)
self.containerView.transform = CGAffineTransform(rotationAngle: component.isFlipped ? .pi : 0.0)
self.containerView.frame = CGRect(origin: .zero, size: pathSize)
return pathSize
}
}
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)
}
}
final class ColorSwatchComponent: Component {
let innerColor: UIColor
let outerColor: UIColor
public init(
innerColor: UIColor,
outerColor: UIColor
) {
self.innerColor = innerColor
self.outerColor = outerColor
}
public static func ==(lhs: ColorSwatchComponent, rhs: ColorSwatchComponent) -> Bool {
if lhs.innerColor != rhs.innerColor {
return false
}
if lhs.outerColor != rhs.outerColor {
return false
}
return true
}
public final class View: UIImageView {
private var component: ColorSwatchComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ColorSwatchComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
if previousComponent?.innerColor != component.innerColor || previousComponent?.outerColor != component.outerColor {
self.image = generateImage(availableSize, contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
if let image = UIImage(bundleImageName: "Premium/Craft/DialColorMask"), let cgImage = image.cgImage {
context.clip(to: CGRect(origin: .zero, size: size), mask: cgImage)
}
var locations: [CGFloat] = [1.0, 0.95, 0.1, 0.0]
let colors: [CGColor] = [component.innerColor.cgColor, component.innerColor.cgColor, component.outerColor.cgColor, component.outerColor.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
})
}
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,330 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import TelegramPresentationData
import ViewControllerComponent
import SheetComponent
import BundleIconComponent
import BalancedTextComponent
import MultilineTextComponent
import ButtonComponent
import GiftItemComponent
import AccountContext
import GlassBarButtonComponent
private func giftCraftRibbonColor(for gift: StarGift.UniqueGift) -> GiftItemComponent.Ribbon.Color {
for attribute in gift.attributes {
if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute {
return .custom(outerColor, innerColor)
}
}
return .blue
}
private final class GiftCraftSheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let gift: StarGift.UniqueGift
let dismiss: () -> Void
init(
context: AccountContext,
gift: StarGift.UniqueGift,
dismiss: @escaping () -> Void
) {
self.context = context
self.gift = gift
self.dismiss = dismiss
}
static func ==(lhs: GiftCraftSheetContent, rhs: GiftCraftSheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gift != rhs.gift {
return false
}
return true
}
static var body: Body {
let closeButton = Child(GlassBarButtonComponent.self)
let title = Child(BalancedTextComponent.self)
let text = Child(MultilineTextComponent.self)
let gift = Child(GiftItemComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let theme = environment.theme
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let closeButton = closeButton.update(
component: GlassBarButtonComponent(
size: CGSize(width: 40.0, height: 40.0),
backgroundColor: theme.rootController.navigationBar.glassBarButtonBackgroundColor,
isDark: theme.overallDarkAppearance,
state: .generic,
component: AnyComponentWithIdentity(
id: "close",
component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.chat.inputPanel.panelControlColor
)
)
),
action: { _ in
component.dismiss()
}
),
availableSize: CGSize(width: 40.0, height: 40.0),
transition: context.transition
)
context.add(closeButton.position(CGPoint(x: environment.safeInsets.left + 16.0 + closeButton.size.width / 2.0, y: 36.0)))
let title = title.update(
component: BalancedTextComponent(
text: .plain(NSAttributedString(string: "Gift Crafting", font: Font.semibold(17.0), textColor: theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 1,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - 96.0, height: context.availableSize.height),
transition: context.transition
)
context.add(title.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0)))
contentSize.height += title.size.height + 16.0
let giftSize = CGSize(width: 140.0, height: 140.0)
let gift = gift.update(
component: GiftItemComponent(
context: component.context,
style: .glass,
theme: theme,
strings: environment.strings,
subject: .uniqueGift(gift: component.gift, price: nil),
ribbon: GiftItemComponent.Ribbon(
text: "#\(component.gift.number)",
font: .monospaced,
color: giftCraftRibbonColor(for: component.gift)
),
mode: .grid
),
availableSize: giftSize,
transition: context.transition
)
context.add(gift.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + gift.size.height / 2.0)))
contentSize.height += gift.size.height + 16.0
let text = text.update(
component: MultilineTextComponent(
text: .plain(
NSAttributedString(
string: "This Swiftgram gift crafting flow is temporarily disabled in the merged build while the underlying APIs are being adapted.",
font: Font.regular(15.0),
textColor: theme.actionSheet.secondaryTextColor,
paragraphAlignment: .center
)
),
maximumNumberOfLines: 0
),
availableSize: CGSize(width: context.availableSize.width - 48.0, height: context.availableSize.height),
transition: context.transition
)
context.add(text.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0)))
contentSize.height += text.size.height + 24.0
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: "ok",
component: AnyComponent(
MultilineTextComponent(
text: .plain(
NSAttributedString(
string: environment.strings.Common_OK,
font: Font.semibold(17.0),
textColor: theme.list.itemCheckColors.foregroundColor,
paragraphAlignment: .center
)
)
)
)
),
action: {
component.dismiss()
}
),
availableSize: CGSize(width: context.availableSize.width - 60.0, height: 52.0),
transition: context.transition
)
context.add(button.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0)).cornerRadius(10.0))
contentSize.height += button.size.height + 16.0 + environment.safeInsets.bottom
return contentSize
}
}
}
private final class GiftCraftScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let gift: StarGift.UniqueGift
init(context: AccountContext, gift: StarGift.UniqueGift) {
self.context = context
self.gift = gift
}
static func ==(lhs: GiftCraftScreenComponent, rhs: GiftCraftScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gift != rhs.gift {
return false
}
return true
}
final class View: UIView {
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>()
private let sheetAnimateOut = ActionSlot<Action<Void>>()
private var component: GiftCraftScreenComponent?
private var environment: EnvironmentType?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(
component: GiftCraftScreenComponent,
availableSize: CGSize,
state: EmptyComponentState,
environment: Environment<ViewControllerComponentContainer.Environment>,
transition: ComponentTransition
) -> CGSize {
self.component = component
let environment = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environment
let sheetEnvironment = SheetComponentEnvironment(
isDisplaying: environment.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { [weak self] _ in
guard let self, let environment = self.environment else {
return
}
self.sheetAnimateOut.invoke(Action { _ in
environment.controller()?.dismiss(completion: nil)
})
}
)
let _ = self.sheet.update(
transition: transition,
component: AnyComponent(
SheetComponent(
content: AnyComponent(
GiftCraftSheetContent(
context: component.context,
gift: component.gift,
dismiss: { [weak self] in
guard let self, let environment = self.environment else {
return
}
self.sheetAnimateOut.invoke(Action { _ in
environment.controller()?.dismiss(completion: nil)
})
}
)
),
backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor),
animateOut: self.sheetAnimateOut
)
),
environment: {
environment
sheetEnvironment
},
containerSize: availableSize
)
if let sheetView = self.sheet.view {
if sheetView.superview == nil {
self.addSubview(sheetView)
}
transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(
view: View,
availableSize: CGSize,
state: EmptyComponentState,
environment: Environment<ViewControllerComponentContainer.Environment>,
transition: ComponentTransition
) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class GiftCraftScreen: ViewControllerComponentContainer {
fileprivate weak var profileGiftsContext: ProfileGiftsContext?
public init(
context: AccountContext,
gift: StarGift.UniqueGift,
profileGiftsContext: ProfileGiftsContext?
) {
self.profileGiftsContext = profileGiftsContext
super.init(
context: context,
component: GiftCraftScreenComponent(context: context, gift: gift),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
presentationMode: .modal,
theme: .default
)
self.navigationPresentation = .flatModal
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
} else {
self.dismiss(completion: nil)
}
}
}
@@ -0,0 +1,714 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramStringFormatting
import ViewControllerComponent
import BundleIconComponent
import MultilineTextComponent
import GiftItemComponent
import AccountContext
import AnimatedTextComponent
import Markdown
import PresentationDataUtils
import GiftViewScreen
import NavigationStackComponent
import GiftStoreScreen
import ResizableSheetComponent
import TooltipUI
import GlassBarButtonComponent
import ConfettiEffect
import GiftLoadingShimmerView
final class SelectGiftPageContent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let craftContext: CraftGiftsContext
let resaleContext: ResaleGiftsContext
let gift: StarGift.UniqueGift
let genericGift: StarGift.Gift
let selectedGiftIds: Set<Int64>
let starsTopUpOptions: Signal<[StarsTopUpOption]?, NoError>
let selectGift: (GiftItem) -> Void
let dismiss: () -> Void
let boundsUpdated: ActionSlot<ResizableSheetComponentEnvironment.BoundsUpdate>
init(
context: AccountContext,
craftContext: CraftGiftsContext,
resaleContext: ResaleGiftsContext,
gift: StarGift.UniqueGift,
genericGift: StarGift.Gift,
selectedGiftIds: Set<Int64>,
starsTopUpOptions: Signal<[StarsTopUpOption]?, NoError>,
selectGift: @escaping (GiftItem) -> Void,
dismiss: @escaping () -> Void,
boundsUpdated: ActionSlot<ResizableSheetComponentEnvironment.BoundsUpdate>
) {
self.context = context
self.craftContext = craftContext
self.resaleContext = resaleContext
self.gift = gift
self.genericGift = genericGift
self.selectedGiftIds = selectedGiftIds
self.starsTopUpOptions = starsTopUpOptions
self.selectGift = selectGift
self.dismiss = dismiss
self.boundsUpdated = boundsUpdated
}
static func ==(lhs: SelectGiftPageContent, rhs: SelectGiftPageContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gift != rhs.gift {
return false
}
if lhs.selectedGiftIds != rhs.selectedGiftIds {
return false
}
return true
}
final class View: UIView, UIScrollViewDelegate {
private let myGiftsTitle = ComponentView<Empty>()
private var gifts: [AnyHashable: ComponentView<Empty>] = [:]
private let myGiftsPlaceholder = ComponentView<Empty>()
private let loadingView = GiftLoadingShimmerView()
private let storeGiftsTitle = ComponentView<Empty>()
private let storeGifts = ComponentView<Empty>()
private var craftState: CraftGiftsContext.State?
private var craftStateDisposable: Disposable?
private var availableGifts: [GiftItem] = []
private var giftMap: [Int64: ProfileGiftsContext.State.StarGift] = [:]
private var availableSize: CGSize?
private var currentBounds: CGRect?
private var component: SelectGiftPageContent?
private weak var state: EmptyComponentState?
private var environment: ViewControllerComponentContainer.Environment?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.cornerRadius = 40.0
self.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
self.addSubview(self.loadingView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.craftStateDisposable?.dispose()
}
func updateScrolling(interactive: Bool, transition: ComponentTransition) -> CGFloat {
guard let bounds = self.currentBounds, let availableSize = self.availableSize, let component = self.component, let environment = self.environment else {
return 0.0
}
let visibleBounds = bounds.insetBy(dx: 0.0, dy: -10.0)
var contentHeight: CGFloat = 88.0 + 32.0
let itemSpacing: CGFloat = 10.0
let itemSideInset = 16.0
let itemsInRow: Int
if availableSize.width > availableSize.height || availableSize.width > 480.0 {
if case .tablet = environment.deviceMetrics.type {
itemsInRow = 4
} else {
itemsInRow = 5
}
} else {
itemsInRow = 3
}
let itemWidth = (availableSize.width - itemSideInset * 2.0 - itemSpacing * CGFloat(itemsInRow - 1)) / CGFloat(itemsInRow)
let itemSize = CGSize(width: itemWidth, height: itemWidth)
var isLoading = false
if self.availableGifts.isEmpty, case .loading = (self.craftState?.dataState ?? .loading) {
isLoading = true
}
let loadingTransition: ComponentTransition = .easeInOut(duration: 0.25)
let loadingSize = CGSize(width: availableSize.width, height: 180.0)
if isLoading {
contentHeight += 120.0
self.loadingView.update(size: loadingSize, theme: environment.theme, itemSize: itemSize, showFilters: false, isPlain: true, transition: .immediate)
loadingTransition.setAlpha(view: self.loadingView, alpha: 1.0)
} else {
loadingTransition.setAlpha(view: self.loadingView, alpha: 0.0)
}
transition.setFrame(view: self.loadingView, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight - 170.0), size: loadingSize))
var itemFrame = CGRect(origin: CGPoint(x: itemSideInset, y: contentHeight), size: itemSize)
var itemsHeight: CGFloat = 0.0
var validIds: [AnyHashable] = []
for gift in self.availableGifts {
var isVisible = false
if visibleBounds.intersects(itemFrame) {
isVisible = true
}
if isVisible {
let itemId = AnyHashable(gift.gift.id)
validIds.append(itemId)
var itemTransition = transition
let visibleItem: ComponentView<Empty>
if let current = self.gifts[itemId] {
visibleItem = current
} else {
visibleItem = ComponentView()
self.gifts[itemId] = visibleItem
itemTransition = .immediate
}
var ribbonColor: GiftItemComponent.Ribbon.Color = .blue
let ribbonText = "#\(gift.gift.number)"
for attribute in gift.gift.attributes {
if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute {
ribbonColor = .custom(outerColor, innerColor)
break
}
}
let _ = visibleItem.update(
transition: itemTransition,
component: AnyComponent(
GiftItemComponent(
context: component.context,
style: .glass,
theme: environment.theme,
strings: environment.strings,
peer: nil,
subject: .uniqueGift(gift: gift.gift, price: nil),
ribbon: GiftItemComponent.Ribbon(text: ribbonText, font: .monospaced, color: ribbonColor, outline: nil),
badge: gift.gift.craftChancePermille.flatMap { "+\($0 / 10)%" },
resellPrice: nil,
isHidden: false,
isSelected: false,
isPinned: false,
isEditing: false,
mode: .grid,
action: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
return
}
HapticFeedback().impact(.light)
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if let profileGift = self.giftMap[gift.gift.id], let canCraftDate = profileGift.canCraftAt, currentTime < canCraftDate {
let dateString = stringForFullDate(timestamp: canCraftDate, strings: environment.strings, dateTimeFormat: environment.dateTimeFormat)
let alertController = textAlertController(
context: component.context,
title: environment.strings.Gift_Craft_Unavailable_Title,
text: environment.strings.Gift_Craft_Unavailable_Text(dateString).string,
actions: [
TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})
],
parseMarkdown: true
)
environment.controller()?.present(alertController, in: .window(.root))
return
}
component.selectGift(gift)
component.dismiss()
},
contextAction: { _, _ in }
)
),
environment: {},
containerSize: itemSize
)
if let itemView = visibleItem.view {
if itemView.superview == nil {
if let _ = self.loadingView.superview {
self.insertSubview(itemView, belowSubview: self.loadingView)
} else {
self.addSubview(itemView)
}
if !transition.animation.isImmediate {
itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
itemTransition.setFrame(view: itemView, frame: itemFrame)
}
}
itemsHeight = itemFrame.maxY - contentHeight
itemFrame.origin.x += itemFrame.width + itemSpacing
if itemFrame.maxX > availableSize.width {
itemFrame.origin.x = itemSideInset
itemFrame.origin.y += itemSize.height + itemSpacing
}
}
var removeIds: [AnyHashable] = []
for (id, item) in self.gifts {
if !validIds.contains(id) {
removeIds.append(id)
if let itemView = item.view {
if !transition.animation.isImmediate {
itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
itemView.removeFromSuperview()
})
} else {
itemView.removeFromSuperview()
}
}
}
}
for id in removeIds {
self.gifts.removeValue(forKey: id)
}
if let state = self.craftState, case .ready = state.dataState, self.availableGifts.isEmpty {
contentHeight += 10.0
let myGiftsPlaceholderSize = self.myGiftsPlaceholder.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: environment.strings.Gift_Craft_Select_NoGiftsFromCollection, font: Font.regular(13.0), textColor: environment.theme.list.itemSecondaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 3,
lineSpacing: 0.1
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - 32.0, height: .greatestFiniteMagnitude)
)
let myGiftsPlaceholderFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - myGiftsPlaceholderSize.width) / 2.0), y: contentHeight), size: myGiftsPlaceholderSize)
if let myGiftsPlaceholderView = self.myGiftsPlaceholder.view {
if myGiftsPlaceholderView.superview == nil {
self.addSubview(myGiftsPlaceholderView)
}
myGiftsPlaceholderView.frame = myGiftsPlaceholderFrame
}
contentHeight += myGiftsPlaceholderSize.height
contentHeight += 32.0
} else {
contentHeight += itemsHeight
contentHeight += 24.0
}
if let storeGiftsView = self.storeGifts.view as? GiftStoreContentComponent.View {
storeGiftsView.updateScrolling(bounds: bounds.offsetBy(dx: 0.0, dy: -contentHeight), interactive: interactive, transition: .immediate)
}
let bottomContentOffset = max(0.0, contentHeight - bounds.origin.y - bounds.height)
if interactive, bottomContentOffset < 800.0 {
Queue.mainQueue().justDispatch {
component.craftContext.loadMore()
}
}
return contentHeight
}
func update(component: SelectGiftPageContent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.availableSize = availableSize
if self.component == nil {
self.currentBounds = CGRect(origin: .zero, size: availableSize)
component.boundsUpdated.connect { [weak self] update in
guard let self else {
return
}
self.currentBounds = update.bounds
let _ = self.updateScrolling(interactive: update.isInteractive, transition: .immediate)
}
let initialGiftItem = GiftItem(
gift: component.gift,
reference: .slug(slug: component.gift.slug)
)
self.availableGifts = [
initialGiftItem
]
self.craftStateDisposable = (component.craftContext.state
|> deliverOnMainQueue).start(next: { [weak self] state in
guard let self else {
return
}
self.craftState = state
var items: [GiftItem] = []
var giftMap: [Int64: ProfileGiftsContext.State.StarGift] = [:]
var existingIds = Set<Int64>()
for gift in state.gifts {
guard let reference = gift.reference, case let .unique(uniqueGift) = gift.gift, !existingIds.contains(uniqueGift.id) else {
continue
}
existingIds.insert(uniqueGift.id)
let giftItem = GiftItem(
gift: uniqueGift,
reference: reference
)
giftMap[uniqueGift.id] = gift
if component.selectedGiftIds.contains(uniqueGift.id) {
continue
}
items.append(giftItem)
}
self.availableGifts = items
self.giftMap = giftMap
if !self.isUpdating {
self.state?.updated(transition: .spring(duration: 0.4))
}
})
}
let environment = environment[ViewControllerComponentContainer.Environment.self].value
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
self.component = component
self.state = state
self.environment = environment
self.backgroundColor = environment.theme.actionSheet.opaqueItemBackgroundColor
var contentHeight: CGFloat = 88.0
let myGiftsTitleSize = self.myGiftsTitle.update(
transition: transition,
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Gift_Craft_Select_YourGifts.uppercased(), font: Font.semibold(14.0), textColor: environment.theme.actionSheet.secondaryTextColor)))
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0)
)
let myGiftsTitleFrame = CGRect(origin: CGPoint(x: 26.0, y: contentHeight), size: myGiftsTitleSize)
if let myGiftsTitleView = self.myGiftsTitle.view {
if myGiftsTitleView.superview == nil {
self.addSubview(myGiftsTitleView)
}
transition.setFrame(view: myGiftsTitleView, frame: myGiftsTitleFrame)
}
contentHeight += 32.0
contentHeight = self.updateScrolling(interactive: false, transition: transition)
let resaleCount = component.genericGift.availability?.resale ?? 0
let saleTitle = environment.strings.Gift_Craft_Select_SaleGiftsCount(Int32(clamping: resaleCount)).uppercased()
let storeGiftsTitleSize = self.storeGiftsTitle.update(
transition: transition,
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: saleTitle, font: Font.semibold(14.0), textColor: environment.theme.actionSheet.secondaryTextColor)))
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0)
)
let storeGiftsTitleFrame = CGRect(origin: CGPoint(x: 26.0, y: contentHeight), size: storeGiftsTitleSize)
if let storeGiftsTitleView = self.storeGiftsTitle.view {
if storeGiftsTitleView.superview == nil {
self.addSubview(storeGiftsTitleView)
}
transition.setFrame(view: storeGiftsTitleView, frame: storeGiftsTitleFrame)
}
contentHeight += 28.0
self.storeGifts.parentState = state
let storeGiftsSize = self.storeGifts.update(
transition: transition,
component: AnyComponent(
GiftStoreContentComponent(
context: component.context,
resaleGiftsContext: component.resaleContext,
theme: environment.theme,
strings: environment.strings,
dateTimeFormat: environment.dateTimeFormat,
safeInsets: UIEdgeInsets(),
statusBarHeight: contentHeight - 62.0,
navigationHeight: 0.0,
overNavigationContainer: self,
starsContext: component.context.starsContext!,
peerId: component.context.account.peerId,
gift: component.genericGift,
isPlain: true,
confirmPurchaseImmediately: true,
starsTopUpOptions: component.starsTopUpOptions,
scrollToTop: {},
controller: environment.controller,
completion: { [weak self] uniqueGift in
guard let self, let component = self.component, let controller = self.environment?.controller() as? SelectCraftGiftScreen, let navigationController = controller.navigationController else {
return
}
let giftItem = GiftItem(gift: uniqueGift, reference: .slug(slug: uniqueGift.slug))
component.selectGift(giftItem)
component.dismiss()
navigationController.view.addSubview(ConfettiView(frame: navigationController.view.bounds))
Queue.mainQueue().after(1.0) {
component.craftContext.reload()
}
}
)
),
environment: {},
containerSize: CGSize(width: availableSize.width, height: .greatestFiniteMagnitude)
)
let storeGiftsFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: storeGiftsSize)
if let storeGiftsView = self.storeGifts.view as? GiftStoreContentComponent.View {
if storeGiftsView.superview == nil {
self.insertSubview(storeGiftsView, at: 0)
}
transition.setFrame(view: storeGiftsView, frame: storeGiftsFrame)
storeGiftsView.updateScrolling(bounds: CGRect(origin: .zero, size: availableSize), transition: .immediate)
}
contentHeight += storeGiftsSize.height
contentHeight += 90.0
return CGSize(width: availableSize.width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class SheetContainerComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let craftContext: CraftGiftsContext
let resaleContext: ResaleGiftsContext
let gift: StarGift.UniqueGift
let genericGift: StarGift.Gift
let selectedGiftIds: Set<Int64>
let starsTopUpOptions: Signal<[StarsTopUpOption]?, NoError>
let selectGift: (GiftItem) -> Void
init(
context: AccountContext,
craftContext: CraftGiftsContext,
resaleContext: ResaleGiftsContext,
gift: StarGift.UniqueGift,
genericGift: StarGift.Gift,
selectedGiftIds: Set<Int64>,
starsTopUpOptions: Signal<[StarsTopUpOption]?, NoError>,
selectGift: @escaping (GiftItem) -> Void
) {
self.context = context
self.craftContext = craftContext
self.resaleContext = resaleContext
self.gift = gift
self.genericGift = genericGift
self.selectedGiftIds = selectedGiftIds
self.starsTopUpOptions = starsTopUpOptions
self.selectGift = selectGift
}
static func ==(lhs: SheetContainerComponent, rhs: SheetContainerComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gift != rhs.gift {
return false
}
return true
}
final class State: ComponentState {
}
func makeState() -> State {
return State()
}
static var body: Body {
let sheet = Child(ResizableSheetComponent<EnvironmentType>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
let boundsUpdated = ActionSlot<ResizableSheetComponentEnvironment.BoundsUpdate>()
return { context in
let component = context.component
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let dismiss: (Bool) -> Void = { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() {
controller.dismiss(completion: nil)
}
}
}
let theme = environment.theme
let backgroundColor = environment.theme.list.modalPlainBackgroundColor
let sheet = sheet.update(
component: ResizableSheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(
SelectGiftPageContent(
context: component.context,
craftContext: component.craftContext,
resaleContext: component.resaleContext,
gift: component.gift,
genericGift: component.genericGift,
selectedGiftIds: component.selectedGiftIds,
starsTopUpOptions: component.starsTopUpOptions,
selectGift: component.selectGift,
dismiss: {
dismiss(true)
},
boundsUpdated: boundsUpdated
)
),
titleItem: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Gift_Craft_Select_Title, font: Font.semibold(17.0), textColor: environment.theme.actionSheet.primaryTextColor)))
),
leftItem: AnyComponent(
GlassBarButtonComponent(
size: CGSize(width: 44.0, height: 44.0),
backgroundColor: nil,
isDark: theme.overallDarkAppearance,
state: .glass,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.chat.inputPanel.panelControlColor
)
)),
action: { _ in
dismiss(true)
}
)
),
rightItem: nil,
bottomItem: nil,
backgroundColor: .color(backgroundColor),
isFullscreen: false,
animateOut: animateOut
),
environment: {
environment
ResizableSheetComponentEnvironment(
theme: theme,
statusBarHeight: environment.statusBarHeight,
safeInsets: environment.safeInsets,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
screenSize: context.availableSize,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
dismiss(animated)
},
boundsUpdated: boundsUpdated
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
final class SelectCraftGiftScreen: ViewControllerComponentContainer {
public init(
context: AccountContext,
craftContext: CraftGiftsContext,
resaleContext: ResaleGiftsContext,
gift: StarGift.UniqueGift,
genericGift: StarGift.Gift,
selectedGiftIds: Set<Int64>,
starsTopUpOptions: Signal<[StarsTopUpOption]?, NoError>,
selectGift: @escaping (GiftItem) -> Void
) {
super.init(
context: context,
component: SheetContainerComponent(
context: context,
craftContext: craftContext,
resaleContext: resaleContext,
gift: gift,
genericGift: genericGift,
selectedGiftIds: selectedGiftIds,
starsTopUpOptions: starsTopUpOptions,
selectGift: selectGift
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func dismissAllTooltips() {
self.window?.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss(inPlace: false)
}
})
self.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss(inPlace: false)
}
return true
})
}
public func dismissAnimated() {
self.dismissAllTooltips()
if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}