2022-01-07 21:03:44 +04:00

834 lines
34 KiB
Swift

import Foundation
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import TelegramPresentationData
import UIKit
import WebPBinding
import AnimatedAvatarSetNode
import ReactionImageComponent
public final class ReactionIconView: PortalSourceView {
public let imageView: UIImageView
override public init(frame: CGRect) {
self.imageView = UIImageView()
super.init(frame: frame)
self.addSubview(self.imageView)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func update(size: CGSize, transition: ContainedViewLayoutTransition) {
transition.updateFrame(view: self.imageView, frame: CGRect(origin: CGPoint(), size: size))
}
}
public final class ReactionButtonAsyncNode: ContextControllerSourceNode {
fileprivate final class ContainerButtonNode: HighlightTrackingButtonNode {
struct Colors: Equatable {
var background: UInt32
var foreground: UInt32
var extractedBackground: UInt32
var extractedForeground: UInt32
}
struct Counter: Equatable {
var components: [CounterLayout.Component]
}
struct Layout: Equatable {
var colors: Colors
var baseSize: CGSize
var counter: Counter?
}
private struct AnimationState {
var fromCounter: Counter
var startTime: Double
var duration: Double
}
private var isExtracted: Bool = false
private var currentLayout: Layout?
private var animationState: AnimationState?
private var animator: ConstantDisplayLinkAnimator?
init() {
super.init(pointerStyle: nil)
}
func update(layout: Layout) {
if self.currentLayout != layout {
if let currentLayout = self.currentLayout, let counter = currentLayout.counter {
self.animationState = AnimationState(fromCounter: counter, startTime: CACurrentMediaTime(), duration: 0.15 * UIView.animationDurationFactor())
}
self.currentLayout = layout
self.updateBackgroundImage(animated: false)
self.updateAnimation()
}
}
private func updateAnimation() {
if let animationState = self.animationState {
let timestamp = CACurrentMediaTime()
if timestamp >= animationState.startTime + animationState.duration {
self.animationState = nil
}
}
if self.animationState != nil {
if self.animator == nil {
let animator = ConstantDisplayLinkAnimator(update: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.updateBackgroundImage(animated: false)
strongSelf.updateAnimation()
})
self.animator = animator
animator.isPaused = false
}
} else if let animator = self.animator {
animator.invalidate()
self.animator = nil
self.updateBackgroundImage(animated: false)
}
}
func updateIsExtracted(isExtracted: Bool, animated: Bool) {
if self.isExtracted != isExtracted {
self.isExtracted = isExtracted
self.updateBackgroundImage(animated: animated)
}
}
private func updateBackgroundImage(animated: Bool) {
guard let layout = self.currentLayout else {
return
}
var totalComponentWidth: CGFloat = 0.0
if let counter = layout.counter {
for component in counter.components {
totalComponentWidth += component.bounds.width
}
}
var imageWidth = layout.baseSize.width
while imageWidth < layout.baseSize.height / 2.0 + 1.0 + totalComponentWidth + 8.0 {
imageWidth += 2.0
}
let image = generateImage(CGSize(width: imageWidth, height: layout.baseSize.height), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
UIGraphicsPushContext(context)
let backgroundColor: UIColor
let foregroundColor: UIColor
if self.isExtracted {
backgroundColor = UIColor(argb: layout.colors.extractedBackground)
foregroundColor = UIColor(argb: layout.colors.extractedForeground)
} else {
backgroundColor = UIColor(argb: layout.colors.background)
foregroundColor = UIColor(argb: layout.colors.foreground)
}
context.setBlendMode(.copy)
context.setFillColor(backgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.height, height: size.height)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height)))
context.fill(CGRect(origin: CGPoint(x: size.height / 2.0, y: 0.0), size: CGSize(width: size.width - size.height, height: size.height)))
#if DEBUG && false
context.setFillColor(UIColor.blue.withAlphaComponent(0.5).cgColor)
context.fill(CGRect(origin: CGPoint(x: layout.baseSize.height / 2.0 + 1.0, y: 0.0), size: CGSize(width: size.width - (layout.baseSize.height / 2.0 + 1.0 + 8.0), height: size.height)))
#endif
if let counter = layout.counter {
let isForegroundTransparent = foregroundColor.alpha < 1.0
context.setBlendMode(isForegroundTransparent ? .copy : .normal)
//let textAreaWidth = size.width - (layout.baseSize.height / 2.0 + 1.0 + 8.0)
var textOrigin: CGFloat = layout.baseSize.height / 2.0 + 1.0
textOrigin = max(textOrigin, layout.baseSize.height / 2.0 + UIScreenPixel)
var rightTextOrigin = textOrigin + totalComponentWidth
let animationFraction: CGFloat
if let animationState = self.animationState {
animationFraction = max(0.0, min(1.0, (CACurrentMediaTime() - animationState.startTime) / animationState.duration))
} else {
animationFraction = 1.0
}
for i in (0 ..< counter.components.count).reversed() {
let component = counter.components[i]
var componentAlpha: CGFloat = 1.0
var componentVerticalOffset: CGFloat = 0.0
if let animationState = self.animationState {
let reverseIndex = counter.components.count - 1 - i
if reverseIndex < animationState.fromCounter.components.count {
let previousComponent = animationState.fromCounter.components[animationState.fromCounter.components.count - 1 - reverseIndex]
if previousComponent != component {
componentAlpha = animationFraction
componentVerticalOffset = (1.0 - animationFraction) * 8.0
if previousComponent.string < component.string {
componentVerticalOffset = -componentVerticalOffset
}
let previousComponentAlpha = 1.0 - componentAlpha
var previousComponentVerticalOffset = -animationFraction * 8.0
if previousComponent.string < component.string {
previousComponentVerticalOffset = -previousComponentVerticalOffset
}
var componentOrigin = rightTextOrigin - previousComponent.bounds.width
componentOrigin = max(componentOrigin, layout.baseSize.height / 2.0 + UIScreenPixel)
let previousColor: UIColor
if isForegroundTransparent {
previousColor = foregroundColor.mixedWith(backgroundColor, alpha: 1.0 - previousComponentAlpha)
} else {
previousColor = foregroundColor.withMultipliedAlpha(previousComponentAlpha)
}
let string = NSAttributedString(string: previousComponent.string, font: Font.medium(11.0), textColor: previousColor)
string.draw(at: previousComponent.bounds.origin.offsetBy(dx: componentOrigin, dy: floorToScreenPixels(size.height - previousComponent.bounds.height) / 2.0 + previousComponentVerticalOffset))
}
}
}
let componentOrigin = rightTextOrigin - component.bounds.width
let currentColor: UIColor
if isForegroundTransparent {
currentColor = foregroundColor.mixedWith(backgroundColor, alpha: 1.0 - componentAlpha)
} else {
currentColor = foregroundColor.withMultipliedAlpha(componentAlpha)
}
let string = NSAttributedString(string: component.string, font: Font.medium(11.0), textColor: currentColor)
string.draw(at: component.bounds.origin.offsetBy(dx: componentOrigin, dy: floorToScreenPixels(size.height - component.bounds.height) / 2.0 + componentVerticalOffset))
rightTextOrigin -= component.bounds.width
}
}
UIGraphicsPopContext()
})?.stretchableImage(withLeftCapWidth: Int(layout.baseSize.height / 2.0), topCapHeight: Int(layout.baseSize.height / 2.0))
if let image = image {
let previousContents = self.layer.contents
ASDisplayNodeSetResizableContents(self.layer, image)
if animated, let previousContents = previousContents {
self.layer.animate(from: previousContents as! CGImage, to: image.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.2)
}
}
}
}
fileprivate final class CounterLayout {
struct Spec: Equatable {
var stringComponents: [String]
}
struct Component: Equatable {
var string: String
var bounds: CGRect
}
private static let maxDigitWidth: CGFloat = {
var maxWidth: CGFloat = 0.0
for i in 0 ... 9 {
let string = NSAttributedString(string: "\(i)", font: Font.medium(11.0), textColor: .black)
let boundingRect = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
maxWidth = max(maxWidth, boundingRect.width)
}
return ceil(maxWidth)
}()
let spec: Spec
let components: [Component]
let size: CGSize
init(
spec: Spec,
components: [Component],
size: CGSize
) {
self.spec = spec
self.components = components
self.size = size
}
static func calculate(spec: Spec, previousLayout: CounterLayout?) -> CounterLayout {
let size: CGSize
let components: [Component]
if let previousLayout = previousLayout, previousLayout.spec == spec {
size = previousLayout.size
components = previousLayout.components
} else {
var resultSize = CGSize()
var resultComponents: [Component] = []
for i in 0 ..< spec.stringComponents.count {
let component = spec.stringComponents[i]
let string = NSAttributedString(string: component, font: Font.medium(11.0), textColor: .black)
let boundingRect = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
resultComponents.append(Component(string: component, bounds: boundingRect))
if i == spec.stringComponents.count - 1 && component.count == 1 && component[component.startIndex].isNumber {
resultSize.width += CounterLayout.maxDigitWidth
} else {
resultSize.width += boundingRect.width
}
resultSize.height = max(resultSize.height, boundingRect.height)
}
size = CGSize(width: ceil(resultSize.width), height: ceil(resultSize.height))
components = resultComponents
}
return CounterLayout(
spec: spec,
components: components,
size: size
)
}
}
fileprivate final class Layout {
struct Spec: Equatable {
var component: ReactionButtonComponent
}
let spec: Spec
let backgroundColor: UInt32
let sideInsets: CGFloat
let imageFrame: CGRect
let imageSize: CGSize
let counterLayout: CounterLayout?
let backgroundLayout: ContainerButtonNode.Layout
let size: CGSize
init(
spec: Spec,
backgroundColor: UInt32,
sideInsets: CGFloat,
imageFrame: CGRect,
imageSize: CGSize,
counterLayout: CounterLayout?,
backgroundLayout: ContainerButtonNode.Layout,
size: CGSize
) {
self.spec = spec
self.backgroundColor = backgroundColor
self.sideInsets = sideInsets
self.imageFrame = imageFrame
self.imageSize = imageSize
self.counterLayout = counterLayout
self.backgroundLayout = backgroundLayout
self.size = size
}
static func calculate(spec: Spec, currentLayout: Layout?) -> Layout {
let sideInsets: CGFloat = 11.0
let height: CGFloat = 30.0
let spacing: CGFloat = 2.0
let boundingImageSize = CGSize(width: 20.0, height: 20.0)
let defaultImageSize = CGSize(width: boundingImageSize.width + floor(boundingImageSize.width * 0.5 * 2.0), height: boundingImageSize.height + floor(boundingImageSize.height * 0.5 * 2.0))
let imageSize: CGSize
if let file = spec.component.reaction.centerAnimation {
imageSize = file.dimensions?.cgSize.aspectFitted(defaultImageSize) ?? defaultImageSize
} else {
imageSize = defaultImageSize
}
var counterComponents: [String] = []
for character in countString(Int64(spec.component.count)) {
counterComponents.append(String(character))
}
#if DEBUG && false
counterComponents.removeAll()
for character in "42" {
counterComponents.append(String(character))
}
#endif
let backgroundColor = spec.component.isSelected ? spec.component.colors.selectedBackground : spec.component.colors.deselectedBackground
let imageFrame = CGRect(origin: CGPoint(x: sideInsets + floorToScreenPixels((boundingImageSize.width - imageSize.width) / 2.0), y: floorToScreenPixels((height - imageSize.height) / 2.0)), size: imageSize)
var counterLayout: CounterLayout?
var size = CGSize(width: boundingImageSize.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 = CounterLayout.Spec(
stringComponents: counterComponents
)
let counterValue: CounterLayout
if let currentCounter = currentLayout?.counterLayout, currentCounter.spec == counterSpec {
counterValue = currentCounter
} else {
counterValue = CounterLayout.calculate(
spec: counterSpec,
previousLayout: currentLayout?.counterLayout
)
}
counterLayout = counterValue
size.width += spacing + counterValue.size.width
}
let backgroundColors = ReactionButtonAsyncNode.ContainerButtonNode.Colors(
background: spec.component.isSelected ? spec.component.colors.selectedBackground : spec.component.colors.deselectedBackground,
foreground: spec.component.isSelected ? spec.component.colors.selectedForeground : spec.component.colors.deselectedForeground,
extractedBackground: spec.component.colors.extractedBackground,
extractedForeground: spec.component.colors.extractedForeground
)
var backgroundCounter: ReactionButtonAsyncNode.ContainerButtonNode.Counter?
if let counterLayout = counterLayout {
backgroundCounter = ReactionButtonAsyncNode.ContainerButtonNode.Counter(
components: counterLayout.components
)
}
let backgroundLayout = ContainerButtonNode.Layout(
colors: backgroundColors,
baseSize: CGSize(width: height + 2.0, height: height),
counter: backgroundCounter
)
return Layout(
spec: spec,
backgroundColor: backgroundColor,
sideInsets: sideInsets,
imageFrame: imageFrame,
imageSize: boundingImageSize,
counterLayout: counterLayout,
backgroundLayout: backgroundLayout,
size: size
)
}
}
private var layout: Layout?
public let containerNode: ContextExtractedContentContainingNode
private let buttonNode: ContainerButtonNode
public let iconView: ReactionIconView
private var avatarsView: AnimatedAvatarSetView?
private let iconImageDisposable = MetaDisposable()
override init() {
self.containerNode = ContextExtractedContentContainingNode()
self.buttonNode = ContainerButtonNode()
self.iconView = ReactionIconView()
self.iconView.isUserInteractionEnabled = false
super.init()
self.targetNodeForActivationProgress = self.containerNode.contentNode
self.addSubnode(self.containerNode)
self.containerNode.contentNode.addSubnode(self.buttonNode)
self.buttonNode.view.addSubview(self.iconView)
self.buttonNode.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
guard let strongSelf = self else {
return
}
let _ = strongSelf
if highlighted {
} else {
}
}
self.isGestureEnabled = true
self.containerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, _ in
guard let strongSelf = self else {
return
}
strongSelf.buttonNode.updateIsExtracted(isExtracted: isExtracted, animated: true)
/*let backgroundImage = isExtracted ? layout.extractedBackgroundImage : layout.backgroundImage
let previousContents = strongSelf.buttonNode.layer.contents
ASDisplayNodeSetResizableContents(strongSelf.buttonNode.layer, backgroundImage)
if let previousContents = previousContents {
strongSelf.buttonNode.layer.animate(from: previousContents as! CGImage, to: backgroundImage.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.2)
}*/
}
}
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) {
self.containerNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.containerNode.contentNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.containerNode.contentRect = CGRect(origin: CGPoint(), size: layout.size)
animation.animator.updateFrame(layer: self.buttonNode.layer, frame: CGRect(origin: CGPoint(), size: layout.size), completion: nil)
//ASDisplayNodeSetResizableContents(self.buttonNode.layer, layout.backgroundImage)
self.buttonNode.update(layout: layout.backgroundLayout)
animation.animator.updateFrame(layer: self.iconView.layer, frame: layout.imageFrame, completion: nil)
self.iconView.update(size: layout.imageFrame.size, transition: animation.transition)
if self.layout?.spec.component.reaction != layout.spec.component.reaction {
if let file = layout.spec.component.reaction.centerAnimation {
self.iconImageDisposable.set((reactionStaticImage(context: layout.spec.component.context, animation: file, pixelSize: CGSize(width: 64.0 * UIScreenScale, height: 64.0 * UIScreenScale))
|> deliverOnMainQueue).start(next: { [weak self] data in
guard let strongSelf = self else {
return
}
if data.isComplete, let dataValue = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
if let image = UIImage(data: dataValue) {
strongSelf.iconView.imageView.image = image
}
}
}))
}
}
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.buttonNode.view.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: floorToScreenPixels(layout.imageFrame.midX + layout.imageSize.width / 2.0) + 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: ReactionButtonAsyncNode?) -> (ReactionButtonComponent) -> (size: CGSize, apply: (_ animation: ListViewItemUpdateAnimation) -> ReactionButtonAsyncNode) {
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)
}
return (size: layout.size, apply: { animation in
var animation = animation
let updatedView: ReactionButtonAsyncNode
if let view = view {
updatedView = view
} else {
updatedView = ReactionButtonAsyncNode()
animation = .None
}
updatedView.apply(layout: layout, animation: animation)
return updatedView
})
}
}
}
public final class ReactionButtonComponent: Equatable {
public struct Reaction: Equatable {
public var value: String
public var centerAnimation: TelegramMediaFile?
public init(value: String, centerAnimation: TelegramMediaFile?) {
self.value = value
self.centerAnimation = centerAnimation
}
public static func ==(lhs: Reaction, rhs: Reaction) -> Bool {
if lhs.value != rhs.value {
return false
}
if lhs.centerAnimation?.fileId != rhs.centerAnimation?.fileId {
return false
}
return true
}
}
public struct Colors: Equatable {
public var deselectedBackground: UInt32
public var selectedBackground: UInt32
public var deselectedForeground: UInt32
public var selectedForeground: UInt32
public var extractedBackground: UInt32
public var extractedForeground: UInt32
public init(
deselectedBackground: UInt32,
selectedBackground: UInt32,
deselectedForeground: UInt32,
selectedForeground: UInt32,
extractedBackground: UInt32,
extractedForeground: UInt32
) {
self.deselectedBackground = deselectedBackground
self.selectedBackground = selectedBackground
self.deselectedForeground = deselectedForeground
self.selectedForeground = selectedForeground
self.extractedBackground = extractedBackground
self.extractedForeground = extractedForeground
}
}
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
public init(
context: AccountContext,
colors: Colors,
reaction: Reaction,
avatarPeers: [EnginePeer],
count: Int,
isSelected: Bool,
action: @escaping (String) -> Void
) {
self.context = context
self.colors = colors
self.reaction = reaction
self.avatarPeers = avatarPeers
self.count = count
self.isSelected = isSelected
self.action = action
}
public static func ==(lhs: ReactionButtonComponent, rhs: ReactionButtonComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.colors != rhs.colors {
return false
}
if lhs.reaction != rhs.reaction {
return false
}
if lhs.avatarPeers != rhs.avatarPeers {
return false
}
if lhs.count != rhs.count {
return false
}
if lhs.isSelected != rhs.isSelected {
return false
}
return true
}
}
public final class ReactionButtonsAsyncLayoutContainer {
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
}
}
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 node: ReactionButtonAsyncNode
public var size: CGSize
}
public var items: [Item]
public var removedNodes: [ReactionButtonAsyncNode]
}
public private(set) var buttons: [String: ReactionButtonAsyncNode] = [:]
public init() {
}
public func update(
context: AccountContext,
action: @escaping (String) -> Void,
reactions: [ReactionButtonsAsyncLayoutContainer.Reaction],
colors: ReactionButtonComponent.Colors,
constrainedWidth: CGFloat
) -> Result {
var items: [Result.Item] = []
var applyItems: [(key: String, size: CGSize, apply: (_ animation: ListViewItemUpdateAnimation) -> ReactionButtonAsyncNode)] = []
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 = ReactionButtonAsyncNode.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 removedNodes: [ReactionButtonAsyncNode] = []
for id in removeIds {
if let node = self.buttons.removeValue(forKey: id) {
removedNodes.append(node)
}
}
return Result(
items: items,
apply: { animation in
var items: [ApplyResult.Item] = []
for (key, size, apply) in applyItems {
let node = apply(animation)
items.append(ApplyResult.Item(value: key, node: node, size: size))
if let current = self.buttons[key] {
assert(current === node)
} else {
self.buttons[key] = node
}
}
return ApplyResult(items: items, removedNodes: removedNodes)
}
)
}
}