Swiftgram/TelegramUI/ItemListRevealOptionsNode.swift
Ilya Laktyushin d20523252f Web Search: recent search queries
Various Fixes
2018-12-11 23:38:59 +04:00

327 lines
12 KiB
Swift

import Foundation
import AsyncDisplayKit
import Display
import Lottie
enum ItemListRevealOptionIcon: Equatable {
case none
case image(image: UIImage)
case animation(animation: String, keysToColor: [String]?)
public static func ==(lhs: ItemListRevealOptionIcon, rhs: ItemListRevealOptionIcon) -> Bool {
switch lhs {
case .none:
if case .none = rhs {
return true
} else {
return false
}
case let .image(lhsImage):
if case let .image(rhsImage) = rhs, lhsImage == rhsImage {
return true
} else {
return false
}
case let .animation(lhsAnimation, lhsKeysToColor):
if case let .animation(rhsAnimation, rhsKeysToColor) = rhs, lhsAnimation == rhsAnimation, lhsKeysToColor == rhsKeysToColor {
return true
} else {
return false
}
}
}
}
struct ItemListRevealOption: Equatable {
let key: Int32
let title: String
let icon: ItemListRevealOptionIcon
let color: UIColor
let textColor: UIColor
static func ==(lhs: ItemListRevealOption, rhs: ItemListRevealOption) -> Bool {
if lhs.key != rhs.key {
return false
}
if lhs.title != rhs.title {
return false
}
if !lhs.color.isEqual(rhs.color) {
return false
}
if !lhs.textColor.isEqual(rhs.textColor) {
return false
}
if lhs.icon != rhs.icon {
return false
}
return true
}
}
private final class ItemListRevealAnimationNode : ASDisplayNode {
var played = false
init(animation: String, keysToColor: [String]?, color: UIColor) {
super.init()
self.setViewBlock({
if let url = frameworkBundle.url(forResource: animation, withExtension: "json"), let composition = LOTComposition(filePath: url.path) {
let view = LOTAnimationView(model: composition, in: frameworkBundle)
if let keysToColor = keysToColor {
for key in keysToColor {
let colorCallback = LOTColorValueCallback(color: color.cgColor)
view.setValueDelegate(colorCallback, for: LOTKeypath(string: "\(key).Color"))
}
}
return view
} else {
return UIView()
}
})
}
func animationView() -> LOTAnimationView? {
return self.view as? LOTAnimationView
}
func play() {
if let animationView = animationView(), !animationView.isAnimationPlaying, !self.played {
self.played = true
animationView.play()
}
}
func reset() {
if self.played, let animationView = animationView() {
self.played = false
animationView.stop()
}
}
func preferredSize() -> CGSize? {
if let animationView = animationView(), let sceneModel = animationView.sceneModel {
return CGSize(width: sceneModel.compBounds.width * 0.3333, height: sceneModel.compBounds.height * 0.3333)
} else {
return nil
}
}
}
private let titleFontWithIcon = Font.medium(13.0)
private let titleFontWithoutIcon = Font.regular(17.0)
private enum ItemListRevealOptionAlignment {
case left
case right
}
private final class ItemListRevealOptionNode: ASDisplayNode {
private let titleNode: ASTextNode
private let iconNode: ASImageNode?
private let animationNode: ItemListRevealAnimationNode?
var alignment: ItemListRevealOptionAlignment?
init(title: String, icon: ItemListRevealOptionIcon, color: UIColor, textColor: UIColor) {
self.titleNode = ASTextNode()
self.titleNode.attributedText = NSAttributedString(string: title, font: icon == .none ? titleFontWithoutIcon : titleFontWithIcon, textColor: textColor)
switch icon {
case let .image(image):
let iconNode = ASImageNode()
iconNode.image = generateTintedImage(image: image, color: textColor)
self.iconNode = iconNode
self.animationNode = nil
case let .animation(animation, keysToColor):
self.iconNode = nil
self.animationNode = ItemListRevealAnimationNode(animation: animation, keysToColor: keysToColor, color: color)
break
case .none:
self.iconNode = nil
self.animationNode = nil
}
super.init()
self.addSubnode(self.titleNode)
if let iconNode = self.iconNode {
self.addSubnode(iconNode)
} else if let animationNode = self.animationNode {
self.addSubnode(animationNode)
}
self.backgroundColor = color
}
func updateLayout(baseSize: CGSize, alignment: ItemListRevealOptionAlignment, extendedWidth: CGFloat, sideInset: CGFloat, transition: ContainedViewLayoutTransition, revealFactor: CGFloat) {
var animateAdditive = false
if transition.isAnimated, self.alignment != alignment {
animateAdditive = true
}
self.alignment = alignment
let titleSize = self.titleNode.calculatedSize
var contentRect = CGRect(origin: CGPoint(), size: baseSize)
switch alignment {
case .left:
contentRect.origin.x = 0.0
case .right:
contentRect.origin.x = extendedWidth - contentRect.width
}
if let animationNode = self.animationNode, let imageSize = animationNode.preferredSize() {
let iconOffset: CGFloat = -2.0
let titleIconSpacing: CGFloat = 11.0
let iconFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - imageSize.width + sideInset) / 2.0), y: contentRect.midY - imageSize.height / 2.0 + iconOffset), size: imageSize)
if animateAdditive {
transition.animatePositionAdditive(node: animationNode, offset: CGPoint(x: animationNode.frame.minX - iconFrame.minX, y: 0.0))
}
animationNode.frame = iconFrame
let titleFrame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - titleSize.width + sideInset) / 2.0), y: contentRect.midY + titleIconSpacing), size: titleSize)
if animateAdditive {
transition.animatePositionAdditive(node: self.titleNode, offset: CGPoint(x: self.titleNode.frame.minX - titleFrame.minX, y: 0.0))
}
self.titleNode.frame = titleFrame
if (abs(revealFactor) >= 0.4) {
animationNode.play()
} else if abs(revealFactor) < CGFloat.ulpOfOne {
animationNode.reset()
}
}
else {
self.titleNode.frame = CGRect(origin: CGPoint(x: contentRect.minX + floor((baseSize.width - titleSize.width + sideInset) / 2.0), y: contentRect.minY + floor((baseSize.height - titleSize.height) / 2.0)), size: titleSize)
}
}
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
let titleSize = self.titleNode.measure(constrainedSize)
var maxWidth = titleSize.width
if let iconNode = self.iconNode, let image = iconNode.image {
maxWidth = max(image.size.width, maxWidth)
}
return CGSize(width: max(74.0, maxWidth + 20.0), height: constrainedSize.height)
}
}
final class ItemListRevealOptionsNode: ASDisplayNode {
private let optionSelected: (ItemListRevealOption) -> Void
private let tapticAction: () -> Void
private var options: [ItemListRevealOption] = []
private var optionNodes: [ItemListRevealOptionNode] = []
private var revealOffset: CGFloat = 0.0
private var sideInset: CGFloat = 0.0
init(optionSelected: @escaping (ItemListRevealOption) -> Void, tapticAction: @escaping () -> Void) {
self.optionSelected = optionSelected
self.tapticAction = tapticAction
super.init()
}
override func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
func setOptions(_ options: [ItemListRevealOption]) {
if self.options != options {
self.options = options
for node in self.optionNodes {
node.removeFromSupernode()
}
self.optionNodes = options.map { option in
return ItemListRevealOptionNode(title: option.title, icon: option.icon, color: option.color, textColor: option.textColor)
}
for node in self.optionNodes {
self.addSubnode(node)
}
self.invalidateCalculatedLayout()
}
}
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
var maxWidth: CGFloat = 0.0
for node in self.optionNodes {
let nodeSize = node.measure(constrainedSize)
maxWidth = max(nodeSize.width, maxWidth)
}
return CGSize(width: maxWidth * CGFloat(self.optionNodes.count), height: constrainedSize.height)
}
func updateRevealOffset(offset: CGFloat, sideInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.revealOffset = offset
self.sideInset = sideInset
self.updateNodesLayout(transition: transition)
}
private func updateNodesLayout(transition: ContainedViewLayoutTransition) {
let size = self.bounds.size
if size.width.isLessThanOrEqualTo(0.0) || self.optionNodes.isEmpty {
return
}
let basicNodeWidth = floorToScreenPixels((size.width - abs(self.sideInset)) / CGFloat(self.optionNodes.count))
let lastNodeWidth = size.width - basicNodeWidth * CGFloat(self.optionNodes.count - 1)
let revealFactor = min(1.0, self.revealOffset / (size.width))
var leftOffset: CGFloat = 0.0
for i in 0 ..< self.optionNodes.count {
let node = self.optionNodes[i]
let nodeWidth = i == (self.optionNodes.count - 1) ? lastNodeWidth : basicNodeWidth
var extendedWidth = nodeWidth
var alignment: ItemListRevealOptionAlignment = .left
var nodeTransition = transition
if self.optionNodes.count == 1 {
extendedWidth = nodeWidth * max(1.0, abs(revealFactor))
if abs(revealFactor) > 1.7 {
alignment = .right
}
}
if let nodeAlignment = node.alignment, alignment != nodeAlignment {
nodeTransition = .animated(duration: 0.2, curve: .spring)
if alignment == .right || !transition.isAnimated {
self.tapticAction()
}
}
var sideInset: CGFloat = 0.0
if i == self.optionNodes.count - 1 {
sideInset = self.sideInset
}
transition.updateFrame(node: node, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(leftOffset * revealFactor), y: 0.0), size: CGSize(width: extendedWidth, height: size.height)))
node.updateLayout(baseSize: CGSize(width: nodeWidth, height: size.height), alignment: alignment, extendedWidth: extendedWidth, sideInset: sideInset, transition: nodeTransition, revealFactor: revealFactor)
leftOffset += nodeWidth
}
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let location = recognizer.location(in: self.view)
for i in 0 ..< self.optionNodes.count {
if self.optionNodes[i].frame.contains(location) {
self.optionSelected(self.options[i])
break
}
}
}
}
func isDisplayingExtendedAction() -> Bool {
if self.optionNodes.count != 1 {
return false
}
for node in self.optionNodes {
if let alignment = node.alignment, case .right = alignment {
return true
}
}
return false
}
}