mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-09-23 21:00:41 +02:00
Update Ghostgram features
This commit is contained in:
+833
@@ -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
|
||||
}
|
||||
}
|
||||
+792
@@ -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
|
||||
)
|
||||
}
|
||||
+295
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+714
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user