mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
473 lines
21 KiB
Swift
473 lines
21 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import AvatarNode
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import AccountContext
|
|
import AudioBlob
|
|
|
|
public final class AnimatedAvatarSetContext {
|
|
public final class Content {
|
|
fileprivate final class Item {
|
|
fileprivate enum Key: Hashable {
|
|
case peer(EnginePeer.Id)
|
|
case placeholder(Int)
|
|
}
|
|
|
|
fileprivate let peer: EnginePeer?
|
|
fileprivate let placeholderColor: UIColor
|
|
|
|
fileprivate init(peer: EnginePeer?, placeholderColor: UIColor) {
|
|
self.peer = peer
|
|
self.placeholderColor = placeholderColor
|
|
}
|
|
}
|
|
|
|
fileprivate var items: [(Item.Key, Item)]
|
|
|
|
fileprivate init(items: [(Item.Key, Item)]) {
|
|
self.items = items
|
|
}
|
|
}
|
|
|
|
private final class ItemState {
|
|
let peer: EnginePeer
|
|
|
|
init(peer: EnginePeer) {
|
|
self.peer = peer
|
|
}
|
|
}
|
|
|
|
private var peers: [EnginePeer] = []
|
|
private var itemStates: [EnginePeer.Id: ItemState] = [:]
|
|
|
|
public init() {
|
|
}
|
|
|
|
public func update(peers: [EnginePeer], animated: Bool) -> Content {
|
|
var items: [(Content.Item.Key, Content.Item)] = []
|
|
for peer in peers {
|
|
items.append((.peer(peer.id), Content.Item(peer: peer, placeholderColor: .white)))
|
|
}
|
|
return Content(items: items)
|
|
}
|
|
|
|
public func updatePlaceholder(color: UIColor, count: Int, animated: Bool) -> Content {
|
|
var items: [(Content.Item.Key, Content.Item)] = []
|
|
for i in 0 ..< count {
|
|
items.append((.placeholder(i), Content.Item(peer: nil, placeholderColor: color)))
|
|
}
|
|
return Content(items: items)
|
|
}
|
|
}
|
|
|
|
private let sharedAvatarFont: UIFont = avatarPlaceholderFont(size: 12.0)
|
|
|
|
private final class ContentNode: ASDisplayNode {
|
|
private let context: AccountContext
|
|
private var audioLevelView: VoiceBlobView?
|
|
private var audioLevelBlobOverlay: UIImageView?
|
|
private let unclippedNode: ASImageNode
|
|
private let clippedNode: ASImageNode
|
|
|
|
private var size: CGSize
|
|
private var spacing: CGFloat
|
|
|
|
private var disposable: Disposable?
|
|
|
|
init(context: AccountContext, peer: EnginePeer?, placeholderColor: UIColor, font: UIFont, synchronousLoad: Bool, size: CGSize, spacing: CGFloat) {
|
|
self.context = context
|
|
self.size = size
|
|
self.spacing = spacing
|
|
|
|
self.unclippedNode = ASImageNode()
|
|
self.clippedNode = ASImageNode()
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.unclippedNode)
|
|
self.addSubnode(self.clippedNode)
|
|
|
|
if let peer = peer {
|
|
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
|
|
let image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.setFillColor(UIColor.lightGray.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
|
})!
|
|
self.updateImage(image: image, size: size, spacing: spacing)
|
|
|
|
let disposable = (signal
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] imageVersions in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let image = imageVersions?.0
|
|
if let image = image {
|
|
strongSelf.updateImage(image: image, size: size, spacing: spacing)
|
|
}
|
|
})
|
|
self.disposable = disposable
|
|
} else {
|
|
let image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
drawPeerAvatarLetters(context: context, size: size, font: font, letters: peer.displayLetters, peerId: peer.id, nameColor: peer.nameColor)
|
|
})!
|
|
self.updateImage(image: image, size: size, spacing: spacing)
|
|
}
|
|
} else {
|
|
let image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.setFillColor(placeholderColor.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
|
})!
|
|
self.updateImage(image: image, size: size, spacing: spacing)
|
|
}
|
|
}
|
|
|
|
private func updateImage(image: UIImage, size: CGSize, spacing: CGFloat) {
|
|
self.unclippedNode.image = image
|
|
self.clippedNode.image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: 1.0, y: -1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size))
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: 1.0, y: -1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
|
|
context.setBlendMode(.copy)
|
|
context.setFillColor(UIColor.clear.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.5, dy: -1.5).offsetBy(dx: spacing - size.width, dy: 0.0))
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
}
|
|
|
|
func updateLayout(size: CGSize, isClipped: Bool, animated: Bool) {
|
|
self.unclippedNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.clippedNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
|
|
if animated && self.unclippedNode.alpha.isZero != self.clippedNode.alpha.isZero {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
|
|
transition.updateAlpha(node: self.unclippedNode, alpha: isClipped ? 0.0 : 1.0)
|
|
transition.updateAlpha(node: self.clippedNode, alpha: isClipped ? 1.0 : 0.0)
|
|
} else {
|
|
self.unclippedNode.alpha = isClipped ? 0.0 : 1.0
|
|
self.clippedNode.alpha = isClipped ? 1.0 : 0.0
|
|
}
|
|
}
|
|
|
|
func updateAudioLevel(color: UIColor, backgroundColor: UIColor, value: Float) {
|
|
if self.audioLevelView == nil, value > 0.0, self.context.sharedContext.energyUsageSettings.fullTranslucency {
|
|
let blobFrame = self.unclippedNode.bounds.insetBy(dx: -8.0, dy: -8.0)
|
|
|
|
let audioLevelView = VoiceBlobView(
|
|
frame: blobFrame,
|
|
maxLevel: 0.3,
|
|
smallBlobRange: (0, 0),
|
|
mediumBlobRange: (0.7, 0.8),
|
|
bigBlobRange: (0.8, 0.9)
|
|
)
|
|
|
|
let maskRect = CGRect(origin: .zero, size: blobFrame.size)
|
|
let playbackMaskLayer = CAShapeLayer()
|
|
playbackMaskLayer.frame = maskRect
|
|
playbackMaskLayer.fillRule = .evenOdd
|
|
let maskPath = UIBezierPath()
|
|
maskPath.append(UIBezierPath(roundedRect: self.unclippedNode.bounds.offsetBy(dx: 8, dy: 8), cornerRadius: maskRect.width / 2.0))
|
|
maskPath.append(UIBezierPath(rect: maskRect))
|
|
playbackMaskLayer.path = maskPath.cgPath
|
|
//audioLevelView.layer.mask = playbackMaskLayer
|
|
|
|
audioLevelView.setColor(color)
|
|
self.audioLevelView = audioLevelView
|
|
self.view.insertSubview(audioLevelView, at: 0)
|
|
}
|
|
|
|
let level = min(1.0, max(0.0, CGFloat(value)))
|
|
if let audioLevelView = self.audioLevelView {
|
|
audioLevelView.updateLevel(CGFloat(value) * 2.0)
|
|
|
|
let avatarScale: CGFloat
|
|
let audioLevelScale: CGFloat
|
|
if value > 0.0 {
|
|
audioLevelView.startAnimating()
|
|
avatarScale = 1.03 + level * 0.07
|
|
audioLevelScale = 1.0
|
|
} else {
|
|
audioLevelView.stopAnimating(duration: 0.5)
|
|
avatarScale = 1.0
|
|
audioLevelScale = 0.01
|
|
}
|
|
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
|
|
transition.updateSublayerTransformScale(node: self, scale: CGPoint(x: avatarScale, y: avatarScale), beginWithCurrentState: true)
|
|
transition.updateSublayerTransformScale(layer: audioLevelView.layer, scale: CGPoint(x: audioLevelScale, y: audioLevelScale), beginWithCurrentState: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
public final class AnimatedAvatarSetNode: ASDisplayNode {
|
|
private var contentNodes: [AnimatedAvatarSetContext.Content.Item.Key: ContentNode] = [:]
|
|
|
|
override public init() {
|
|
super.init()
|
|
}
|
|
|
|
public func update(context: AccountContext, content: AnimatedAvatarSetContext.Content, itemSize: CGSize = CGSize(width: 30.0, height: 30.0), customSpacing: CGFloat? = nil, font: UIFont? = nil, animated: Bool, synchronousLoad: Bool) -> CGSize {
|
|
var contentWidth: CGFloat = 0.0
|
|
let contentHeight: CGFloat = itemSize.height
|
|
|
|
let spacing: CGFloat
|
|
if let customSpacing = customSpacing {
|
|
spacing = customSpacing
|
|
} else {
|
|
spacing = 10.0
|
|
}
|
|
|
|
let transition: ContainedViewLayoutTransition
|
|
if animated {
|
|
transition = .animated(duration: 0.2, curve: .easeInOut)
|
|
} else {
|
|
transition = .immediate
|
|
}
|
|
|
|
var validKeys: [AnimatedAvatarSetContext.Content.Item.Key] = []
|
|
var index = 0
|
|
for i in 0 ..< content.items.count {
|
|
let (key, item) = content.items[i]
|
|
|
|
validKeys.append(key)
|
|
|
|
let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize)
|
|
|
|
let itemNode: ContentNode
|
|
if let current = self.contentNodes[key] {
|
|
itemNode = current
|
|
itemNode.updateLayout(size: itemSize, isClipped: index != 0, animated: animated)
|
|
transition.updateFrame(node: itemNode, frame: itemFrame)
|
|
} else {
|
|
itemNode = ContentNode(context: context, peer: item.peer, placeholderColor: item.placeholderColor, font: font ?? sharedAvatarFont, synchronousLoad: synchronousLoad, size: itemSize, spacing: spacing)
|
|
self.addSubnode(itemNode)
|
|
self.contentNodes[key] = itemNode
|
|
itemNode.updateLayout(size: itemSize, isClipped: index != 0, animated: false)
|
|
itemNode.frame = itemFrame
|
|
if animated {
|
|
itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
itemNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5)
|
|
}
|
|
}
|
|
itemNode.zPosition = CGFloat(100 - i)
|
|
if i == content.items.count - 1 {
|
|
contentWidth += itemSize.width
|
|
} else {
|
|
contentWidth += itemSize.width - spacing
|
|
}
|
|
index += 1
|
|
}
|
|
var removeKeys: [AnimatedAvatarSetContext.Content.Item.Key] = []
|
|
for key in self.contentNodes.keys {
|
|
if !validKeys.contains(key) {
|
|
removeKeys.append(key)
|
|
}
|
|
}
|
|
for key in removeKeys {
|
|
guard let itemNode = self.contentNodes.removeValue(forKey: key) else {
|
|
continue
|
|
}
|
|
if animated {
|
|
itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemNode] _ in
|
|
itemNode?.removeFromSupernode()
|
|
})
|
|
itemNode.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false)
|
|
} else {
|
|
itemNode.removeFromSupernode()
|
|
}
|
|
}
|
|
|
|
return CGSize(width: contentWidth, height: contentHeight)
|
|
}
|
|
|
|
public func updateAudioLevels(color: UIColor, backgroundColor: UIColor, levels: [EnginePeer.Id: Float]) {
|
|
for (key, itemNode) in self.contentNodes {
|
|
if case let .peer(peerId) = key, let value = levels[peerId] {
|
|
itemNode.updateAudioLevel(color: color, backgroundColor: backgroundColor, value: value)
|
|
} else {
|
|
itemNode.updateAudioLevel(color: color, backgroundColor: backgroundColor, value: 0.0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public final class AnimatedAvatarSetView: UIView {
|
|
private final class ContentView: UIView {
|
|
private let unclippedView: UIImageView
|
|
private let clippedView: UIImageView
|
|
|
|
private var size: CGSize
|
|
private var spacing: CGFloat
|
|
|
|
private var disposable: Disposable?
|
|
|
|
init(context: AccountContext, peer: EnginePeer?, placeholderColor: UIColor, font: UIFont, synchronousLoad: Bool, size: CGSize, spacing: CGFloat) {
|
|
self.size = size
|
|
self.spacing = spacing
|
|
|
|
self.unclippedView = UIImageView()
|
|
self.clippedView = UIImageView()
|
|
|
|
super.init(frame: CGRect())
|
|
|
|
self.addSubview(self.unclippedView)
|
|
self.addSubview(self.clippedView)
|
|
|
|
if let peer = peer {
|
|
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
|
|
let image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.setFillColor(UIColor.lightGray.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
|
})!
|
|
self.updateImage(image: image, size: size, spacing: spacing)
|
|
|
|
let disposable = (signal
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] imageVersions in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let image = imageVersions?.0
|
|
if let image = image {
|
|
strongSelf.updateImage(image: image, size: size, spacing: spacing)
|
|
}
|
|
})
|
|
self.disposable = disposable
|
|
} else {
|
|
let image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
drawPeerAvatarLetters(context: context, size: size, font: font, letters: peer.displayLetters, peerId: peer.id, nameColor: peer.nameColor)
|
|
})!
|
|
self.updateImage(image: image, size: size, spacing: spacing)
|
|
}
|
|
} else {
|
|
let image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.setFillColor(placeholderColor.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
|
})!
|
|
self.updateImage(image: image, size: size, spacing: spacing)
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func updateImage(image: UIImage, size: CGSize, spacing: CGFloat) {
|
|
self.unclippedView.image = image
|
|
self.clippedView.image = generateImage(size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: 1.0, y: -1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size))
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: 1.0, y: -1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
|
|
context.setBlendMode(.copy)
|
|
context.setFillColor(UIColor.clear.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.5, dy: -1.5).offsetBy(dx: spacing - size.width, dy: 0.0))
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
}
|
|
|
|
func updateLayout(size: CGSize, isClipped: Bool, animated: Bool) {
|
|
self.unclippedView.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.clippedView.frame = CGRect(origin: CGPoint(), size: size)
|
|
|
|
if animated && self.unclippedView.alpha.isZero != self.clippedView.alpha.isZero {
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
|
|
transition.updateAlpha(layer: self.unclippedView.layer, alpha: isClipped ? 0.0 : 1.0)
|
|
transition.updateAlpha(layer: self.clippedView.layer, alpha: isClipped ? 1.0 : 0.0)
|
|
} else {
|
|
self.unclippedView.alpha = isClipped ? 0.0 : 1.0
|
|
self.clippedView.alpha = isClipped ? 1.0 : 0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
private var contentViews: [AnimatedAvatarSetContext.Content.Item.Key: ContentView] = [:]
|
|
|
|
public func update(context: AccountContext, content: AnimatedAvatarSetContext.Content, itemSize: CGSize = CGSize(width: 30.0, height: 30.0), customSpacing: CGFloat? = nil, font: UIFont? = nil, animation: ListViewItemUpdateAnimation, synchronousLoad: Bool) -> CGSize {
|
|
var contentWidth: CGFloat = 0.0
|
|
let contentHeight: CGFloat = itemSize.height
|
|
|
|
let spacing: CGFloat
|
|
if let customSpacing = customSpacing {
|
|
spacing = customSpacing
|
|
} else {
|
|
spacing = 10.0
|
|
}
|
|
|
|
var validKeys: [AnimatedAvatarSetContext.Content.Item.Key] = []
|
|
var index = 0
|
|
for i in 0 ..< content.items.count {
|
|
let (key, item) = content.items[i]
|
|
|
|
validKeys.append(key)
|
|
|
|
let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize)
|
|
|
|
let itemView: ContentView
|
|
if let current = self.contentViews[key] {
|
|
itemView = current
|
|
itemView.updateLayout(size: itemSize, isClipped: index != 0, animated: animation.isAnimated)
|
|
animation.animator.updateFrame(layer: itemView.layer, frame: itemFrame, completion: nil)
|
|
} else {
|
|
itemView = ContentView(context: context, peer: item.peer, placeholderColor: item.placeholderColor, font: font ?? sharedAvatarFont, synchronousLoad: synchronousLoad, size: itemSize, spacing: spacing)
|
|
self.addSubview(itemView)
|
|
self.contentViews[key] = itemView
|
|
itemView.updateLayout(size: itemSize, isClipped: index != 0, animated: false)
|
|
itemView.frame = itemFrame
|
|
if animation.isAnimated {
|
|
itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
itemView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5)
|
|
}
|
|
}
|
|
itemView.layer.zPosition = CGFloat(100 - i)
|
|
contentWidth += itemSize.width - spacing
|
|
index += 1
|
|
}
|
|
var removeKeys: [AnimatedAvatarSetContext.Content.Item.Key] = []
|
|
for key in self.contentViews.keys {
|
|
if !validKeys.contains(key) {
|
|
removeKeys.append(key)
|
|
}
|
|
}
|
|
for key in removeKeys {
|
|
guard let itemView = self.contentViews.removeValue(forKey: key) else {
|
|
continue
|
|
}
|
|
if animation.isAnimated {
|
|
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemView] _ in
|
|
itemView?.removeFromSuperview()
|
|
})
|
|
itemView.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false)
|
|
} else {
|
|
itemView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
return CGSize(width: contentWidth, height: contentHeight)
|
|
}
|
|
}
|