Temp: reaction improvements

This commit is contained in:
Ali
2021-12-10 19:34:57 +04:00
parent ec675606ee
commit 4a8f28b866
55 changed files with 1383 additions and 251 deletions

View File

@@ -1,4 +1,5 @@
import Foundation
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
@@ -8,6 +9,426 @@ import AccountContext
import TelegramPresentationData
import UIKit
import WebPBinding
import AnimatedAvatarSetNode
private final class NullActionClass: NSObject, CAAction {
@objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
}
}
private let nullAction = NullActionClass()
private final class SimpleLayer: CALayer {
override func action(forKey event: String) -> CAAction? {
return nullAction
}
override init() {
super.init()
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
fileprivate final class CounterLayer: CALayer {
fileprivate final class Layout {
struct Spec: Equatable {
let clippingHeight: CGFloat
var stringComponents: [String]
var backgroundColor: UInt32
var foregroundColor: UInt32
}
let spec: Spec
let size: CGSize
let image: UIImage
init(
spec: Spec,
size: CGSize,
image: UIImage
) {
self.spec = spec
self.size = size
self.image = image
}
static func calculate(spec: Spec, previousLayout: Layout?) -> Layout {
let image: UIImage
if let previousLayout = previousLayout, previousLayout.spec == spec {
image = previousLayout.image
} else {
let textColor = UIColor(argb: spec.foregroundColor)
let string = NSAttributedString(string: spec.stringComponents.joined(separator: ""), font: Font.medium(11.0), textColor: textColor)
let boundingRect = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
image = generateImage(CGSize(width: boundingRect.size.width, height: spec.clippingHeight), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
/*context.setFillColor(UIColor(argb: spec.backgroundColor).cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
if textColor.alpha < 1.0 {
context.setBlendMode(.copy)
}*/
context.translateBy(x: 0.0, y: (size.height - boundingRect.size.height) / 2.0)
UIGraphicsPushContext(context)
string.draw(at: CGPoint())
UIGraphicsPopContext()
})!
}
return Layout(
spec: spec,
size: image.size,
image: image
)
}
}
var layout: Layout?
override init(layer: Any) {
super.init(layer: layer)
}
override init() {
super.init()
self.masksToBounds = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func action(forKey event: String) -> CAAction? {
return nullAction
}
func apply(layout: Layout, animation: ListViewItemUpdateAnimation) {
/*if animation.isAnimated, let previousContents = self.contents {
self.animate(from: previousContents as! CGImage, to: layout.image.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.2)
} else {*/
self.contents = layout.image.cgImage
//}
self.layout = layout
}
}
public final class ReactionButtonAsyncView: UIButton {
fileprivate final class Layout {
struct Spec: Equatable {
var component: ReactionButtonComponent
}
let spec: Spec
let backgroundColor: UInt32
let clippingHeight: CGFloat
let sideInsets: CGFloat
let imageFrame: CGRect
let counter: CounterLayer.Layout?
let counterFrame: CGRect?
let backgroundImage: UIImage
let size: CGSize
init(
spec: Spec,
backgroundColor: UInt32,
clippingHeight: CGFloat,
sideInsets: CGFloat,
imageFrame: CGRect,
counter: CounterLayer.Layout?,
counterFrame: CGRect?,
backgroundImage: UIImage,
size: CGSize
) {
self.spec = spec
self.backgroundColor = backgroundColor
self.clippingHeight = clippingHeight
self.sideInsets = sideInsets
self.imageFrame = imageFrame
self.counter = counter
self.counterFrame = counterFrame
self.backgroundImage = backgroundImage
self.size = size
}
static func calculate(spec: Spec, currentLayout: Layout?, currentCounter: CounterLayer.Layout?) -> Layout {
let clippingHeight: CGFloat = 22.0
let sideInsets: CGFloat = 8.0
let height: CGFloat = 30.0
let spacing: CGFloat = 4.0
let defaultImageSize = CGSize(width: 22.0, height: 22.0)
let imageSize: CGSize
if let file = spec.component.reaction.iconFile {
imageSize = file.dimensions?.cgSize.aspectFitted(defaultImageSize) ?? defaultImageSize
} else {
imageSize = defaultImageSize
}
var counterComponents: [String] = []
for character in "\(spec.component.count)" {
counterComponents.append(String(character))
}
let backgroundColor = spec.component.isSelected ? spec.component.colors.selectedBackground : spec.component.colors.deselectedBackground
let imageFrame = CGRect(origin: CGPoint(x: sideInsets, y: floorToScreenPixels((height - imageSize.height) / 2.0)), size: imageSize)
var previousDisplayCounter: String?
if let currentLayout = currentLayout {
if currentLayout.spec.component.avatarPeers.isEmpty {
previousDisplayCounter = "\(spec.component.count)"
}
}
var currentDisplayCounter: String?
if spec.component.avatarPeers.isEmpty {
currentDisplayCounter = "\(spec.component.count)"
}
let backgroundImage: UIImage
if let currentLayout = currentLayout, currentLayout.spec.component.isSelected == spec.component.isSelected, currentLayout.spec.component.colors == spec.component.colors, previousDisplayCounter == currentDisplayCounter {
backgroundImage = currentLayout.backgroundImage
} else {
backgroundImage = generateImage(CGSize(width: height + 18.0, height: height), rotatedContext: { size, context in
UIGraphicsPushContext(context)
context.clear(CGRect(origin: CGPoint(), size: size))
context.setBlendMode(.copy)
context.setFillColor(UIColor(argb: backgroundColor).cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: height, height: height)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - height, y: 0.0), size: CGSize(width: height, height: size.height)))
context.fill(CGRect(origin: CGPoint(x: height / 2.0, y: 0.0), size: CGSize(width: size.width - height, height: size.height)))
context.setBlendMode(.normal)
if let currentDisplayCounter = currentDisplayCounter {
let textColor = UIColor(argb: spec.component.isSelected ? spec.component.colors.selectedForeground : spec.component.colors.deselectedForeground)
let string = NSAttributedString(string: currentDisplayCounter, font: Font.medium(11.0), textColor: textColor)
let boundingRect = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
if textColor.alpha < 1.0 {
context.setBlendMode(.copy)
}
string.draw(at: CGPoint(x: size.width - sideInsets - boundingRect.width, y: (size.height - boundingRect.height) / 2.0))
}
UIGraphicsPopContext()
})!.stretchableImage(withLeftCapWidth: Int(height / 2.0), topCapHeight: Int(height / 2.0))
}
var counter: CounterLayer.Layout?
var counterFrame: CGRect?
var size = CGSize(width: imageSize.width + sideInsets * 2.0, height: height)
if !spec.component.avatarPeers.isEmpty {
size.width += 4.0 + 24.0
if spec.component.avatarPeers.count > 1 {
size.width += CGFloat(spec.component.avatarPeers.count - 1) * 12.0
} else {
size.width -= 2.0
}
} else {
let counterSpec = CounterLayer.Layout.Spec(
clippingHeight: clippingHeight,
stringComponents: counterComponents,
backgroundColor: backgroundColor,
foregroundColor: spec.component.isSelected ? spec.component.colors.selectedForeground : spec.component.colors.deselectedForeground
)
let counterValue: CounterLayer.Layout
if let currentCounter = currentCounter, currentCounter.spec == counterSpec {
counterValue = currentCounter
} else {
counterValue = CounterLayer.Layout.calculate(
spec: counterSpec,
previousLayout: currentCounter
)
}
counter = counterValue
size.width += spacing + counterValue.size.width
counterFrame = CGRect(origin: CGPoint(x: sideInsets + imageSize.width + spacing, y: floorToScreenPixels((height - counterValue.size.height) / 2.0)), size: counterValue.size)
}
return Layout(
spec: spec,
backgroundColor: backgroundColor,
clippingHeight: clippingHeight,
sideInsets: sideInsets,
imageFrame: imageFrame,
counter: counter,
counterFrame: counterFrame,
backgroundImage: backgroundImage,
size: size
)
}
}
private var layout: Layout?
public let iconView: UIImageView
private var counterLayer: CounterLayer?
private var avatarsView: AnimatedAvatarSetView?
private let iconImageDisposable = MetaDisposable()
override init(frame: CGRect) {
self.iconView = UIImageView()
self.iconView.isUserInteractionEnabled = false
super.init(frame: CGRect())
self.addSubview(self.iconView)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
deinit {
self.iconImageDisposable.dispose()
}
@objc private func pressed() {
guard let layout = self.layout else {
return
}
layout.spec.component.action(layout.spec.component.reaction.value)
}
fileprivate func apply(layout: Layout, animation: ListViewItemUpdateAnimation) {
let backgroundCapInsets = layout.backgroundImage.capInsets
if backgroundCapInsets.left.isZero && backgroundCapInsets.top.isZero {
self.layer.contentsScale = layout.backgroundImage.scale
self.layer.contents = layout.backgroundImage.cgImage
} else {
ASDisplayNodeSetResizableContents(self.layer, layout.backgroundImage)
}
animation.animator.updateFrame(layer: self.iconView.layer, frame: layout.imageFrame, completion: nil)
if self.layout?.spec.component.reaction != layout.spec.component.reaction {
if let file = layout.spec.component.reaction.iconFile {
self.iconImageDisposable.set((layout.spec.component.context.account.postbox.mediaBox.resourceData(file.resource)
|> deliverOnMainQueue).start(next: { [weak self] data in
guard let strongSelf = self else {
return
}
if data.complete, let dataValue = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
if let image = WebP.convert(fromWebP: dataValue) {
strongSelf.iconView.image = image
}
}
}))
}
}
if let counter = layout.counter, let counterFrame = layout.counterFrame {
let counterLayer: CounterLayer
var counterAnimation = animation
if let current = self.counterLayer {
counterLayer = current
} else {
counterAnimation = .None
counterLayer = CounterLayer()
self.counterLayer = counterLayer
//self.layer.addSublayer(counterLayer)
if animation.isAnimated {
counterLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
counterAnimation.animator.updateFrame(layer: counterLayer, frame: counterFrame, completion: nil)
counterLayer.apply(layout: counter, animation: counterAnimation)
} else if let counterLayer = self.counterLayer {
self.counterLayer = nil
if animation.isAnimated {
animation.animator.updateAlpha(layer: counterLayer, alpha: 0.0, completion: { [weak counterLayer] _ in
counterLayer?.removeFromSuperlayer()
})
} else {
counterLayer.removeFromSuperlayer()
}
}
if !layout.spec.component.avatarPeers.isEmpty {
let avatarsView: AnimatedAvatarSetView
if let current = self.avatarsView {
avatarsView = current
} else {
avatarsView = AnimatedAvatarSetView()
avatarsView.isUserInteractionEnabled = false
self.avatarsView = avatarsView
self.addSubview(avatarsView)
}
let content = AnimatedAvatarSetContext().update(peers: layout.spec.component.avatarPeers, animated: false)
let avatarsSize = avatarsView.update(
context: layout.spec.component.context,
content: content,
itemSize: CGSize(width: 24.0, height: 24.0),
customSpacing: 10.0,
animation: animation,
synchronousLoad: false
)
animation.animator.updateFrame(layer: avatarsView.layer, frame: CGRect(origin: CGPoint(x: layout.imageFrame.maxX + 4.0, y: floorToScreenPixels((layout.size.height - avatarsSize.height) / 2.0)), size: CGSize(width: avatarsSize.width, height: avatarsSize.height)), completion: nil)
} else if let avatarsView = self.avatarsView {
self.avatarsView = nil
if animation.isAnimated {
animation.animator.updateAlpha(layer: avatarsView.layer, alpha: 0.0, completion: { [weak avatarsView] _ in
avatarsView?.removeFromSuperview()
})
animation.animator.updateScale(layer: avatarsView.layer, scale: 0.01, completion: nil)
} else {
avatarsView.removeFromSuperview()
}
}
self.layout = layout
}
public static func asyncLayout(_ view: ReactionButtonAsyncView?) -> (ReactionButtonComponent) -> (size: CGSize, apply: (_ animation: ListViewItemUpdateAnimation) -> ReactionButtonAsyncView) {
let currentLayout = view?.layout
return { component in
let spec = Layout.Spec(component: component)
let layout: Layout
if let currentLayout = currentLayout, currentLayout.spec == spec {
layout = currentLayout
} else {
layout = Layout.calculate(spec: spec, currentLayout: currentLayout, currentCounter: currentLayout?.counter)
}
return (size: layout.size, apply: { animation in
var animation = animation
let updatedView: ReactionButtonAsyncView
if let view = view {
updatedView = view
} else {
updatedView = ReactionButtonAsyncView()
animation = .None
}
updatedView.apply(layout: layout, animation: animation)
return updatedView
})
}
}
}
public final class ReactionButtonComponent: Component {
public struct ViewTag: Equatable {
@@ -60,6 +481,7 @@ public final class ReactionButtonComponent: Component {
public let context: AccountContext
public let colors: Colors
public let reaction: Reaction
public let avatarPeers: [EnginePeer]
public let count: Int
public let isSelected: Bool
public let action: (String) -> Void
@@ -68,6 +490,7 @@ public final class ReactionButtonComponent: Component {
context: AccountContext,
colors: Colors,
reaction: Reaction,
avatarPeers: [EnginePeer],
count: Int,
isSelected: Bool,
action: @escaping (String) -> Void
@@ -75,6 +498,7 @@ public final class ReactionButtonComponent: Component {
self.context = context
self.colors = colors
self.reaction = reaction
self.avatarPeers = avatarPeers
self.count = count
self.isSelected = isSelected
self.action = action
@@ -90,6 +514,9 @@ public final class ReactionButtonComponent: Component {
if lhs.reaction != rhs.reaction {
return false
}
if lhs.avatarPeers != rhs.avatarPeers {
return false
}
if lhs.count != rhs.count {
return false
}
@@ -247,19 +674,136 @@ public final class ReactionButtonComponent: Component {
}
}
public final class ReactionButtonsAsyncLayoutContainer {
public struct Result {
public struct Item {
public var size: CGSize
}
public var items: [Item]
public var apply: (ListViewItemUpdateAnimation) -> ApplyResult
}
public struct ApplyResult {
public struct Item {
public var value: String
public var view: ReactionButtonAsyncView
public var size: CGSize
}
public var items: [Item]
public var removedViews: [ReactionButtonAsyncView]
}
public private(set) var buttons: [String: ReactionButtonAsyncView] = [:]
public init() {
}
public func update(
context: AccountContext,
action: @escaping (String) -> Void,
reactions: [ReactionButtonsLayoutContainer.Reaction],
colors: ReactionButtonComponent.Colors,
constrainedWidth: CGFloat
) -> Result {
var items: [Result.Item] = []
var applyItems: [(key: String, size: CGSize, apply: (_ animation: ListViewItemUpdateAnimation) -> ReactionButtonAsyncView)] = []
var validIds = Set<String>()
for reaction in reactions.sorted(by: { lhs, rhs in
var lhsCount = lhs.count
if lhs.isSelected {
lhsCount -= 1
}
var rhsCount = rhs.count
if rhs.isSelected {
rhsCount -= 1
}
if lhsCount != rhsCount {
return lhsCount > rhsCount
}
return lhs.reaction.value < rhs.reaction.value
}) {
validIds.insert(reaction.reaction.value)
var avatarPeers = reaction.peers
for i in 0 ..< avatarPeers.count {
if avatarPeers[i].id == context.account.peerId {
let peer = avatarPeers[i]
avatarPeers.remove(at: i)
avatarPeers.insert(peer, at: 0)
break
}
}
let viewLayout = ReactionButtonAsyncView.asyncLayout(self.buttons[reaction.reaction.value])
let (size, apply) = viewLayout(ReactionButtonComponent(
context: context,
colors: colors,
reaction: reaction.reaction,
avatarPeers: avatarPeers,
count: reaction.count,
isSelected: reaction.isSelected,
action: action
))
items.append(Result.Item(
size: size
))
applyItems.append((reaction.reaction.value, size, apply))
}
var removeIds: [String] = []
for (id, _) in self.buttons {
if !validIds.contains(id) {
removeIds.append(id)
}
}
var removedViews: [ReactionButtonAsyncView] = []
for id in removeIds {
if let view = self.buttons.removeValue(forKey: id) {
removedViews.append(view)
}
}
return Result(
items: items,
apply: { animation in
var items: [ApplyResult.Item] = []
for (key, size, apply) in applyItems {
let view = apply(animation)
items.append(ApplyResult.Item(value: key, view: view, size: size))
if let current = self.buttons[key] {
assert(current === view)
} else {
self.buttons[key] = view
}
}
return ApplyResult(items: items, removedViews: removedViews)
}
)
}
}
public final class ReactionButtonsLayoutContainer {
public struct Reaction {
public var reaction: ReactionButtonComponent.Reaction
public var count: Int
public var peers: [EnginePeer]
public var isSelected: Bool
public init(
reaction: ReactionButtonComponent.Reaction,
count: Int,
peers: [EnginePeer],
isSelected: Bool
) {
self.reaction = reaction
self.count = count
self.peers = peers
self.isSelected = isSelected
}
}
@@ -322,6 +866,7 @@ public final class ReactionButtonsLayoutContainer {
context: context,
colors: colors,
reaction: reaction.reaction,
avatarPeers: reaction.peers,
count: reaction.count,
isSelected: reaction.isSelected,
action: action