Merge branch 'beta'

This commit is contained in:
Isaac 2025-08-05 17:44:17 +02:00
commit e16f2ff47f
16 changed files with 730 additions and 270 deletions

View File

@ -4613,44 +4613,46 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
let _ = (combineLatest(self.chatListDisplayNode.mainContainerNode.currentItemNode.contentsReady |> take(1), self.context.account.postbox.tailChatListView(groupId: .root, count: 16, summaryComponents: ChatListEntrySummaryComponents(components: [:])) |> take(1))
|> deliverOnMainQueue).startStandalone(next: { [weak self] _, chatListView in
guard let strongSelf = self else {
return
}
/*if let scrollToTop = strongSelf.scrollToTop {
scrollToTop()
}*/
let tabsIsEmpty: Bool
if let (resolvedItems, displayTabsAtBottom, _) = strongSelf.tabContainerData {
tabsIsEmpty = resolvedItems.count <= 1 || displayTabsAtBottom
} else {
tabsIsEmpty = true
}
let _ = tabsIsEmpty
//TODO:swap tabs
let displaySearchFilters = true
if let filterContainerNodeAndActivate = strongSelf.chatListDisplayNode.activateSearch(placeholderNode: searchContentNode.placeholderNode, displaySearchFilters: displaySearchFilters, hasDownloads: strongSelf.hasDownloads, initialFilter: filter, navigationController: strongSelf.navigationController as? NavigationController) {
let (filterContainerNode, activate) = filterContainerNodeAndActivate
if displaySearchFilters {
let searchTabsNode = SparseNode()
strongSelf.searchTabsNode = searchTabsNode
searchTabsNode.addSubnode(filterContainerNode)
Task { @MainActor in
guard let strongSelf = self else {
return
}
activate(filter != .downloads)
/*if let scrollToTop = strongSelf.scrollToTop {
scrollToTop()
}*/
if let searchContentNode = strongSelf.chatListDisplayNode.searchDisplayController?.contentNode as? ChatListSearchContainerNode {
searchContentNode.search(filter: filter, query: query)
let tabsIsEmpty: Bool
if let (resolvedItems, displayTabsAtBottom, _) = strongSelf.tabContainerData {
tabsIsEmpty = resolvedItems.count <= 1 || displayTabsAtBottom
} else {
tabsIsEmpty = true
}
let _ = tabsIsEmpty
//TODO:swap tabs
let displaySearchFilters = true
if let filterContainerNodeAndActivate = await strongSelf.chatListDisplayNode.activateSearch(placeholderNode: searchContentNode.placeholderNode, displaySearchFilters: displaySearchFilters, hasDownloads: strongSelf.hasDownloads, initialFilter: filter, navigationController: strongSelf.navigationController as? NavigationController) {
let (filterContainerNode, activate) = filterContainerNodeAndActivate
if displaySearchFilters {
let searchTabsNode = SparseNode()
strongSelf.searchTabsNode = searchTabsNode
searchTabsNode.addSubnode(filterContainerNode)
}
activate(filter != .downloads)
if let searchContentNode = strongSelf.chatListDisplayNode.searchDisplayController?.contentNode as? ChatListSearchContainerNode {
searchContentNode.search(filter: filter, query: query)
}
}
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)
strongSelf.setDisplayNavigationBar(false, transition: transition)
(strongSelf.parent as? TabBarController)?.updateIsTabBarHidden(true, transition: .animated(duration: 0.4, curve: .spring))
}
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)
strongSelf.setDisplayNavigationBar(false, transition: transition)
(strongSelf.parent as? TabBarController)?.updateIsTabBarHidden(true, transition: .animated(duration: 0.4, curve: .spring))
})
self.isSearchActive = true

View File

@ -20,6 +20,7 @@ import ComponentFlow
import ChatFolderLinkPreviewScreen
import ChatListHeaderComponent
import StoryPeerListComponent
import TelegramNotices
public enum ChatListContainerNodeFilter: Equatable {
case all
@ -1649,7 +1650,8 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate {
}
}
func activateSearch(placeholderNode: SearchBarPlaceholderNode, displaySearchFilters: Bool, hasDownloads: Bool, initialFilter: ChatListSearchFilter, navigationController: NavigationController?) -> (ASDisplayNode, (Bool) -> Void)? {
@MainActor
func activateSearch(placeholderNode: SearchBarPlaceholderNode, displaySearchFilters: Bool, hasDownloads: Bool, initialFilter: ChatListSearchFilter, navigationController: NavigationController?) async -> (ASDisplayNode, (Bool) -> Void)? {
guard let (containerLayout, _, _, cleanNavigationBarHeight, _) = self.containerLayout, self.searchDisplayController == nil else {
return nil
}
@ -1688,6 +1690,9 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate {
self?.controller?.openAdInfo(node: node, adPeer: adPeer)
}
let searchTips = await ApplicationSpecificNotice.getGlobalPostsSearch(accountManager: self.context.sharedContext.accountManager).get()
contentNode.displayGlobalPostsNewBadge = searchTips < 3
self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, mode: .list, contentNode: contentNode, cancel: { [weak self] in
if let requestDeactivateSearch = self?.requestDeactivateSearch {
requestDeactivateSearch()

View File

@ -131,6 +131,7 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo
private var forumPeer: EnginePeer?
private var hasPublicPostsTab = false
private var showPublicPostsTab = false
public var displayGlobalPostsNewBadge = false
private var shareStatusDisposable: MetaDisposable?
@ -715,7 +716,7 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo
filters = defaultAvailableSearchPanes(isForum: isForum, hasDownloads: !isForum && self.hasDownloads, hasPublicPosts: self.showPublicPostsTab).map(\.filter)
}
self.filterContainerNode.update(size: CGSize(width: layout.size.width - 40.0, height: 38.0), sideInset: layout.safeInsets.left - 20.0, filters: filters.map { .filter($0) }, displayGlobalPostsNewBadge: true, selectedFilter: self.selectedFilter?.id, transitionFraction: self.transitionFraction, presentationData: self.presentationData, transition: transition)
self.filterContainerNode.update(size: CGSize(width: layout.size.width - 40.0, height: 38.0), sideInset: layout.safeInsets.left - 20.0, filters: filters.map { .filter($0) }, displayGlobalPostsNewBadge: self.displayGlobalPostsNewBadge, selectedFilter: self.selectedFilter?.id, transitionFraction: self.transitionFraction, presentationData: self.presentationData, transition: transition)
}
}
@ -786,7 +787,7 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo
}
let overflowInset: CGFloat = 20.0
self.filterContainerNode.update(size: CGSize(width: layout.size.width - overflowInset * 2.0, height: 38.0), sideInset: layout.safeInsets.left - overflowInset, filters: filters.map { .filter($0) }, displayGlobalPostsNewBadge: true, selectedFilter: self.selectedFilter?.id, transitionFraction: self.transitionFraction, presentationData: self.presentationData, transition: .animated(duration: 0.4, curve: .spring))
self.filterContainerNode.update(size: CGSize(width: layout.size.width - overflowInset * 2.0, height: 38.0), sideInset: layout.safeInsets.left - overflowInset, filters: filters.map { .filter($0) }, displayGlobalPostsNewBadge: self.displayGlobalPostsNewBadge, selectedFilter: self.selectedFilter?.id, transitionFraction: self.transitionFraction, presentationData: self.presentationData, transition: .animated(duration: 0.4, curve: .spring))
if isFirstTime {
self.filterContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)

View File

@ -2061,7 +2061,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
}
var defaultFoundRemoteMessagesSignal: Signal<([FoundRemoteMessages], Bool), NoError> = .single(([FoundRemoteMessages(messages: [], readCounters: [:], threadsData: [:], totalCount: 0)], false))
if key == .globalPosts {
if key == .globalPosts, let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_load_empty_global_posts"] as? Double, value != 0.0 {
let searchSignal = context.engine.messages.searchMessages(location: .general(scope: .globalPosts(allowPaidStars: nil), tags: nil, minDate: nil, maxDate: nil), query: "", state: nil, limit: 50)
|> map { resultData -> ChatListSearchMessagesResult in
let (result, updatedState) = resultData

View File

@ -9,6 +9,7 @@ import AccountContext
import ContextUI
import AnimationCache
import MultiAnimationRenderer
import TelegramNotices
protocol ChatListSearchPaneNode: ASDisplayNode {
var isReady: Signal<Bool, NoError> { get }
@ -238,6 +239,11 @@ final class ChatListSearchPaneContainerNode: ASDisplayNode, ASGestureRecognizerD
}
return
}
if key == .globalPosts {
let _ = ApplicationSpecificNotice.incrementGlobalPostsSearch(accountManager: self.context.sharedContext.accountManager).startStandalone()
}
#if DEBUG
#else
self.isAdjacentLoadingEnabled = true

View File

@ -4,6 +4,23 @@ import Display
import ComponentFlow
import TelegramPresentationData
extension ComponentTransition {
func animateBlur(layer: CALayer, from: CGFloat, to: CGFloat, delay: Double = 0.0, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) {
if let blurFilter = CALayer.blur() {
blurFilter.setValue(to as NSNumber, forKey: "inputRadius")
layer.filters = [blurFilter]
layer.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.3, delay: delay, removeOnCompletion: removeOnCompletion, completion: { [weak layer] _ in
guard let layer else {
return
}
if to == 0.0 && removeOnCompletion {
layer.filters = nil
}
})
}
}
}
public final class AnimatedTextComponent: Component {
public struct Item: Equatable {
public enum Content: Equatable {
@ -27,19 +44,25 @@ public final class AnimatedTextComponent: Component {
public let items: [Item]
public let noDelay: Bool
public let animateScale: Bool
public let preferredDirectionIsDown: Bool
public let blur: Bool
public init(
font: UIFont,
color: UIColor,
items: [Item],
noDelay: Bool = false,
animateScale: Bool = true
animateScale: Bool = true,
preferredDirectionIsDown: Bool = false,
blur: Bool = false
) {
self.font = font
self.color = color
self.items = items
self.noDelay = noDelay
self.animateScale = animateScale
self.preferredDirectionIsDown = preferredDirectionIsDown
self.blur = blur
}
public static func ==(lhs: AnimatedTextComponent, rhs: AnimatedTextComponent) -> Bool {
@ -58,6 +81,12 @@ public final class AnimatedTextComponent: Component {
if lhs.animateScale != rhs.animateScale {
return false
}
if lhs.preferredDirectionIsDown != rhs.preferredDirectionIsDown {
return false
}
if lhs.blur != rhs.blur {
return false
}
return true
}
@ -88,8 +117,14 @@ public final class AnimatedTextComponent: Component {
var size = CGSize()
let delayNorm: CGFloat = 0.002
var offsetNorm: CGFloat = 0.4
let transitionBlurRadius: CGFloat = 6.0
var firstDelayWidth: CGFloat?
if component.preferredDirectionIsDown {
firstDelayWidth = 0.0
offsetNorm = 0.8
}
var validKeys: [CharacterKey] = []
for item in component.items {
@ -119,6 +154,57 @@ public final class AnimatedTextComponent: Component {
index += 1
validKeys.append(characterKey)
}
}
var outLastDelayWidth: CGFloat?
var outFirstDelayWidth: CGFloat?
if component.preferredDirectionIsDown {
for (key, characterView) in self.characters {
if !validKeys.contains(key), let characterView = characterView.view {
if let outFirstDelayWidthValue = outFirstDelayWidth {
outFirstDelayWidth = max(outFirstDelayWidthValue, characterView.frame.center.x)
} else {
outFirstDelayWidth = characterView.frame.center.x
}
if let outLastDelayWidthValue = outLastDelayWidth {
outLastDelayWidth = min(outLastDelayWidthValue, characterView.frame.center.x)
} else {
outLastDelayWidth = characterView.frame.center.x
}
}
}
}
if outLastDelayWidth != nil {
firstDelayWidth = outLastDelayWidth
}
for item in component.items {
var itemText: [String] = []
switch item.content {
case let .text(text):
if item.isUnbreakable {
itemText = [text]
} else {
itemText = text.map(String.init)
}
case let .number(value, minDigits):
var valueText: String = "\(value)"
while valueText.count < minDigits {
valueText.insert("0", at: valueText.startIndex)
}
if item.isUnbreakable {
itemText = [valueText]
} else {
itemText = valueText.map(String.init)
}
}
var index = 0
for character in itemText {
let characterKey = CharacterKey(itemId: item.id, index: index, value: character)
index += 1
var characterTransition = transition
let characterView: ComponentView<Empty>
@ -155,7 +241,11 @@ public final class AnimatedTextComponent: Component {
} else {
var delayWidth: Double = 0.0
if let firstDelayWidth {
delayWidth = size.width - firstDelayWidth
if characterFrame.midX > characterComponentView.frame.midX {
delayWidth = 0.0
} else {
delayWidth = abs(size.width - firstDelayWidth)
}
} else {
firstDelayWidth = size.width
}
@ -170,7 +260,7 @@ public final class AnimatedTextComponent: Component {
if animateIn, !transition.animation.isImmediate {
var delayWidth: Double = 0.0
if !component.noDelay {
if !component.noDelay || component.preferredDirectionIsDown {
if let firstDelayWidth {
delayWidth = size.width - firstDelayWidth
} else {
@ -181,7 +271,10 @@ public final class AnimatedTextComponent: Component {
if component.animateScale {
characterComponentView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.4, delay: delayNorm * delayWidth, timingFunction: kCAMediaTimingFunctionSpring)
}
characterComponentView.layer.animatePosition(from: CGPoint(x: 0.0, y: characterSize.height * 0.5), to: CGPoint(), duration: 0.4, delay: delayNorm * delayWidth, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
if component.blur {
ComponentTransition.easeInOut(duration: 0.2).animateBlur(layer: characterComponentView.layer, from: transitionBlurRadius, to: 0.0, delay: delayNorm * delayWidth)
}
characterComponentView.layer.animatePosition(from: CGPoint(x: 0.0, y: characterSize.height * offsetNorm), to: CGPoint(), duration: 0.4, delay: delayNorm * delayWidth, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
characterComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18, delay: delayNorm * delayWidth)
}
}
@ -194,8 +287,6 @@ public final class AnimatedTextComponent: Component {
let outScaleTransition: ComponentTransition = .spring(duration: 0.4)
let outAlphaTransition: ComponentTransition = .easeInOut(duration: 0.18)
var outFirstDelayWidth: CGFloat?
var removedKeys: [CharacterKey] = []
for (key, characterView) in self.characters {
if !validKeys.contains(key) {
@ -205,18 +296,28 @@ public final class AnimatedTextComponent: Component {
if !transition.animation.isImmediate {
var delayWidth: Double = 0.0
if let outFirstDelayWidth {
delayWidth = characterComponentView.frame.minX - outFirstDelayWidth
delayWidth = abs(characterComponentView.frame.midX - outFirstDelayWidth)
} else {
outFirstDelayWidth = characterComponentView.frame.minX
outFirstDelayWidth = characterComponentView.frame.midX
}
delayWidth = max(0.0, delayWidth)
if component.animateScale {
outScaleTransition.setScale(view: characterComponentView, scale: 0.01, delay: delayNorm * delayWidth)
}
outScaleTransition.setPosition(view: characterComponentView, position: CGPoint(x: characterComponentView.center.x, y: characterComponentView.center.y - characterComponentView.bounds.height * 0.4), delay: delayNorm * delayWidth)
let targetY: CGFloat
if component.preferredDirectionIsDown {
targetY = characterComponentView.center.y - characterComponentView.bounds.height * offsetNorm
} else {
targetY = characterComponentView.center.y - characterComponentView.bounds.height * offsetNorm
}
outScaleTransition.setPosition(view: characterComponentView, position: CGPoint(x: characterComponentView.center.x, y: targetY), delay: delayNorm * delayWidth)
outAlphaTransition.setAlpha(view: characterComponentView, alpha: 0.0, delay: delayNorm * delayWidth, completion: { [weak characterComponentView] _ in
characterComponentView?.removeFromSuperview()
})
if component.blur {
outAlphaTransition.animateBlur(layer: characterComponentView.layer, from: 0.0, to: transitionBlurRadius, delay: delayNorm * delayWidth, removeOnCompletion: false)
}
} else {
characterComponentView.removeFromSuperview()
}

View File

@ -338,6 +338,10 @@ public final class PeerInfoRatingComponent: Component {
context.clear(CGRect(origin: CGPoint(), size: size))
if level < 0 {
return
}
if let url = Bundle.main.url(forResource: "profile_level\(levelIndex)_outer", withExtension: "svg"), let data = try? Data(contentsOf: url) {
if let image = generateTintedImage(image: drawSvgImage(data, size, nil, nil, 0.0, false), color: component.borderColor) {
image.draw(in: CGRect(origin: CGPoint(x: 0.0, y: backgroundOffsetsY[levelIndex] ?? 0.0), size: size), blendMode: .normal, alpha: 1.0)
@ -360,6 +364,13 @@ public final class PeerInfoRatingComponent: Component {
context.clear(CGRect(origin: CGPoint(), size: size))
if level < 0 {
if let image = generateTintedImage(image: UIImage(bundleImageName: "Peer Info/InlineRatingWarning"), color: component.backgroundColor) {
image.draw(in: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) * 0.5), y: floorToScreenPixels((size.height - image.size.height) * 0.5)), size: image.size))
}
return
}
if let url = Bundle.main.url(forResource: "profile_level\(levelIndex)_inner", withExtension: "svg"), let data = try? Data(contentsOf: url) {
if let image = generateTintedImage(image: drawSvgImage(data, size, nil, nil, 0.0, false), color: component.backgroundColor) {
image.draw(in: CGRect(origin: CGPoint(x: 0.0, y: backgroundOffsetsY[levelIndex] ?? 0.0), size: size), blendMode: .normal, alpha: 1.0)

View File

@ -781,6 +781,13 @@ final class PeerInfoHeaderNode: ASDisplayNode {
self.avatarClippingNode.clipsToBounds = true
}
let accentRatingBackgroundColor: UIColor
if let currentStarRating = self.currentStarRating, currentStarRating.level < 0 {
accentRatingBackgroundColor = UIColor(rgb: 0xFF3B30)
} else {
accentRatingBackgroundColor = presentationData.theme.list.itemCheckColors.fillColor
}
let ratingBackgroundColor: UIColor
let ratingBorderColor: UIColor
let ratingForegroundColor: UIColor
@ -796,7 +803,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
headerButtonBackgroundColor = collapsedHeaderButtonBackgroundColor
ratingBackgroundColor = presentationData.theme.list.itemCheckColors.fillColor
ratingBackgroundColor = accentRatingBackgroundColor
ratingBorderColor = .clear
ratingForegroundColor = presentationData.theme.list.itemCheckColors.foregroundColor
} else if self.isAvatarExpanded {
@ -833,7 +840,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
if let status = peer?.emojiStatus, case let .starGift(_, _, _, _, _, innerColor, outerColor, patternColorValue, _) = status.content {
let _ = innerColor
ratingBackgroundColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(presentationData.theme.list.itemCheckColors.fillColor, alpha: effectiveTransitionFraction)
ratingBackgroundColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(accentRatingBackgroundColor, alpha: effectiveTransitionFraction)
let innerColor = UIColor(rgb: UInt32(bitPattern: innerColor))
let outerColor = UIColor(rgb: UInt32(bitPattern: outerColor))
@ -855,29 +862,10 @@ final class PeerInfoHeaderNode: ASDisplayNode {
ratingBorderColor = patternColor.withAlphaComponent(0.1).blendOver(background: backgroundColor).mixedWith(.clear, alpha: effectiveTransitionFraction)
ratingForegroundColor = ratingBorderColor.mixedWith(presentationData.theme.list.itemCheckColors.foregroundColor, alpha: effectiveTransitionFraction)
} else {
ratingBackgroundColor = presentationData.theme.list.itemCheckColors.fillColor
ratingBackgroundColor = accentRatingBackgroundColor
ratingBorderColor = UIColor.clear
ratingForegroundColor = presentationData.theme.list.itemCheckColors.foregroundColor
}
/*if let status = peer?.emojiStatus, case let .starGift(_, _, _, _, _, innerColor, outerColor, _, _) = status.content {
let _ = outerColor
let mainColor = UIColor(rgb: UInt32(bitPattern: innerColor))
ratingBackgroundColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(presentationData.theme.list.itemCheckColors.fillColor, alpha: effectiveTransitionFraction)
ratingForegroundColor = mainColor.withMultiplied(hue: 1.0, saturation: 1.1, brightness: 0.9).mixedWith(UIColor.clear, alpha: effectiveTransitionFraction)
ratingBorderColor = ratingForegroundColor.mixedWith(presentationData.theme.list.itemCheckColors.foregroundColor, alpha: effectiveTransitionFraction)
} else if let profileColor = peer?.profileColor {
let backgroundColors = self.context.peerNameColors.getProfile(profileColor, dark: presentationData.theme.overallDarkAppearance)
ratingBackgroundColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(presentationData.theme.list.itemCheckColors.fillColor, alpha: effectiveTransitionFraction)
ratingForegroundColor = backgroundColors.main.withMultiplied(hue: 1.0, saturation: 1.1, brightness: 0.9).mixedWith(UIColor.clear, alpha: effectiveTransitionFraction)
ratingBorderColor = ratingForegroundColor.mixedWith(presentationData.theme.list.itemCheckColors.foregroundColor, alpha: effectiveTransitionFraction)
} else {
ratingBackgroundColor = presentationData.theme.list.itemCheckColors.fillColor
ratingBorderColor = UIColor.clear
ratingForegroundColor = presentationData.theme.list.itemCheckColors.foregroundColor
}*/
}
do {
@ -1986,20 +1974,30 @@ final class PeerInfoHeaderNode: ASDisplayNode {
if let cachedData = cachedData as? CachedUserData, let starRating = cachedData.starRating {
self.currentStarRating = starRating
self.currentPendingStarRating = cachedData.pendingStarRating
#if DEBUG
if let _ = starRating.nextLevelStars {
self.currentPendingStarRating = TelegramStarPendingRating(rating: TelegramStarRating(level: starRating.level, currentLevelStars: starRating.currentLevelStars, stars: starRating.stars + 234, nextLevelStars: starRating.nextLevelStars), timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 3)
self.currentPendingStarRating = TelegramStarPendingRating(rating: TelegramStarRating(level: starRating.level + 1, currentLevelStars: starRating.nextLevelStars!, stars: starRating.nextLevelStars! + starRating.nextLevelStars! / 2 + starRating.nextLevelStars! / 4, nextLevelStars: starRating.nextLevelStars! * 2), timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 3)
}
#endif
} else {
self.currentStarRating = nil
self.currentPendingStarRating = nil
}
if let cachedData = cachedData as? CachedUserData, let starRating = cachedData.starRating {
//if "".isEmpty {
#if DEBUG
if "".isEmpty {
let starRating: TelegramStarRating
if self.context.account.peerId.id._internalGetInt64Value() == 654152421 {
starRating = TelegramStarRating(level: -1, currentLevelStars: -1, stars: -100, nextLevelStars: 0)
} else {
starRating = TelegramStarRating(level: 2, currentLevelStars: 1000, stars: 2000, nextLevelStars: 3000)
}
self.currentStarRating = starRating
if let _ = starRating.nextLevelStars {
//self.currentPendingStarRating = TelegramStarPendingRating(rating: TelegramStarRating(level: starRating.level, currentLevelStars: starRating.currentLevelStars, stars: starRating.stars + 234, nextLevelStars: starRating.nextLevelStars), timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 3)
self.currentPendingStarRating = TelegramStarPendingRating(rating: TelegramStarRating(level: starRating.level + 2, currentLevelStars: starRating.nextLevelStars!, stars: max(500, starRating.nextLevelStars! + starRating.nextLevelStars! / 2 - starRating.nextLevelStars! / 4), nextLevelStars: max(1000, starRating.nextLevelStars! * 2)), timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 3)
}
}
#endif
if let starRating = self.currentStarRating {
let subtitleRating: ComponentView<Empty>
var subtitleRatingTransition = ComponentTransition(transition)
if let current = self.subtitleRating {
@ -2018,7 +2016,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
foregroundColor: ratingForegroundColor,
level: Int(starRating.level),
action: { [weak self] in
guard let self, let peer, let currentStarRating = self.currentStarRating else {
guard let self, let peer = self.peer, let currentStarRating = self.currentStarRating else {
return
}
self.controller?.push(ProfileLevelInfoScreen(

View File

@ -17,6 +17,7 @@ import PlainButtonComponent
import Markdown
import PremiumUI
import LottieComponent
import AnimatedTextComponent
private final class ProfileLevelInfoScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
@ -376,10 +377,38 @@ private final class ProfileLevelInfoScreenComponent: Component {
descriptionTextString = environment.strings.ProfileLevelInfo_OtherDescription(component.peer.compactDisplayTitle).string
}
//TODO:localize
var titleItems: [AnimatedTextComponent.Item] = []
if self.isPreviewingPendingRating {
titleItems.append(AnimatedTextComponent.Item(
id: AnyHashable(0),
isUnbreakable: false,
content: .text("Future ")
))
titleItems.append(AnimatedTextComponent.Item(
id: AnyHashable(1),
isUnbreakable: true,
content: .text("Rating")
))
} else {
titleItems.append(AnimatedTextComponent.Item(
id: AnyHashable(1),
isUnbreakable: true,
content: .text("Rating")
))
}
let _ = titleString
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor))
transition: transition,
component: AnyComponent(AnimatedTextComponent(
font: Font.semibold(17.0),
color: environment.theme.list.itemPrimaryTextColor,
items: titleItems,
noDelay: true,
animateScale: false,
preferredDirectionIsDown: true,
blur: true
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0)
@ -389,7 +418,7 @@ private final class ProfileLevelInfoScreenComponent: Component {
if titleView.superview == nil {
self.navigationBarContainer.addSubview(titleView)
}
titleView.frame = titleFrame
transition.setFrame(view: titleView, frame: titleFrame)
}
contentHeight += 56.0
@ -433,47 +462,25 @@ private final class ProfileLevelInfoScreenComponent: Component {
if let nextLevelStars = component.starRating.nextLevelStars {
badgeTextSuffix = " / \(starCountString(Int64(nextLevelStars), decimalSeparator: "."))"
}
if let nextLevelStars = component.starRating.nextLevelStars {
if component.starRating.stars < 0 {
levelFraction = 0.5
} else if let nextLevelStars = component.starRating.nextLevelStars {
levelFraction = Double(component.starRating.stars - component.starRating.currentLevelStars) / Double(nextLevelStars - component.starRating.currentLevelStars)
} else if component.starRating.stars > 0 {
levelFraction = 1.0
} else {
levelFraction = 0.0
levelFraction = 1.0
}
}
levelFraction = max(0.0, levelFraction)
/*let levelInfoSize = self.levelInfo.update(
transition: .immediate,
component: AnyComponent(PremiumLimitDisplayComponent(
inactiveColor: environment.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.5),
activeColors: gradientColors,
inactiveTitle: environment.strings.ProfileLevelInfo_LevelIndex(Int32(currentLevel)),
inactiveValue: "",
inactiveTitleColor: environment.theme.list.itemPrimaryTextColor,
activeTitle: "",
activeValue: nextLevel.flatMap { environment.strings.ProfileLevelInfo_LevelIndex(Int32($0)) } ?? "",
activeTitleColor: .white,
badgeIconName: "Peer Info/ProfileLevelProgressIcon",
badgeText: badgeText,
badgeTextSuffix: badgeTextSuffix,
badgePosition: levelFraction,
badgeGraphPosition: levelFraction,
invertProgress: true,
isPremiumDisabled: false
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 200.0)
)*/
let _ = levelFraction
//TODO:localize
let levelInfoSize = self.levelInfo.update(
transition: isChangingPreview ? ComponentTransition.immediate.withUserData(ProfileLevelRatingBarComponent.TransitionHint(animate: true)) : .immediate,
component: AnyComponent(ProfileLevelRatingBarComponent(
theme: environment.theme,
value: levelFraction,
leftLabel: environment.strings.ProfileLevelInfo_LevelIndex(Int32(currentLevel)),
rightLabel: nextLevel.flatMap { environment.strings.ProfileLevelInfo_LevelIndex(Int32($0)) } ?? "",
leftLabel: currentLevel < 0 ? "" : environment.strings.ProfileLevelInfo_LevelIndex(Int32(currentLevel)),
rightLabel: currentLevel < 0 ? "Negative rating" : nextLevel.flatMap { environment.strings.ProfileLevelInfo_LevelIndex(Int32($0)) } ?? "",
badgeValue: badgeText,
badgeTotal: badgeTextSuffix,
level: Int(currentLevel)
@ -491,12 +498,21 @@ private final class ProfileLevelInfoScreenComponent: Component {
contentHeight += 129.0
if let secondaryDescriptionTextString {
let changingPreviewAnimationOffset: CGFloat = self.isPreviewingPendingRating ? -100.0 : 100.0
let transitionBlurRadius: CGFloat = 10.0
if isChangingPreview, let secondaryDescriptionTextView = self.secondaryDescriptionText?.view {
self.secondaryDescriptionText = nil
transition.setTransform(view: secondaryDescriptionTextView, transform: CATransform3DMakeScale(0.9, 0.9, 1.0))
transition.setTransform(view: secondaryDescriptionTextView, transform: CATransform3DMakeTranslation(changingPreviewAnimationOffset, 0.0, 0.0))
alphaTransition.setAlpha(view: secondaryDescriptionTextView, alpha: 0.0, completion: { [weak secondaryDescriptionTextView] _ in
secondaryDescriptionTextView?.removeFromSuperview()
})
if let blurFilter = CALayer.blur() {
blurFilter.setValue(transitionBlurRadius as NSNumber, forKey: "inputRadius")
secondaryDescriptionTextView.layer.filters = [blurFilter]
secondaryDescriptionTextView.layer.animate(from: 0.0 as NSNumber, to: transitionBlurRadius as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.3, removeOnCompletion: false)
}
}
contentHeight -= 8.0
@ -510,9 +526,16 @@ private final class ProfileLevelInfoScreenComponent: Component {
self.secondaryDescriptionText = secondaryDescriptionText
}
let secondaryTextColor: UIColor
if currentLevel < 0 {
secondaryTextColor = UIColor(rgb: 0xFF3B30)
} else {
secondaryTextColor = environment.theme.list.itemSecondaryTextColor
}
let secondaryDescriptionAttributedString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(secondaryDescriptionTextString, attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemSecondaryTextColor),
bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.itemSecondaryTextColor),
body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: secondaryTextColor),
bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: secondaryTextColor),
link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemAccentColor),
linkAttribute: { url in
return ("URL", url)
@ -563,8 +586,16 @@ private final class ProfileLevelInfoScreenComponent: Component {
if secondaryDescriptionTextView.superview == nil {
self.scrollContentView.addSubview(secondaryDescriptionTextView)
if isChangingPreview {
transition.animateScale(view: secondaryDescriptionTextView, from: 0.9, to: 1.0)
transition.animatePosition(view: secondaryDescriptionTextView, from: CGPoint(x: -changingPreviewAnimationOffset, y: 0.0), to: CGPoint(), additive: true)
alphaTransition.animateAlpha(view: secondaryDescriptionTextView, from: 0.0, to: 1.0)
if let blurFilter = CALayer.blur() {
blurFilter.setValue(transitionBlurRadius as NSNumber, forKey: "inputRadius")
secondaryDescriptionTextView.layer.filters = [blurFilter]
secondaryDescriptionTextView.layer.animate(from: transitionBlurRadius as NSNumber, to: 0.0 as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.3, removeOnCompletion: false, completion: { [weak secondaryDescriptionTextView] _ in
secondaryDescriptionTextView?.layer.filters = nil
})
}
}
}
secondaryDescriptionTextTransition.setPosition(view: secondaryDescriptionTextView, position: secondaryDescriptionTextFrame.center)

View File

@ -47,11 +47,12 @@ final class ProfileLevelRatingBarBadge: Component {
final class View: UIView {
private let badgeView: UIView
private let badgeMaskView: UIView
private let badgeShapeLayer = SimpleShapeLayer()
private let badgeShapeView: UIImageView
private let badgeShapeAnimation = ComponentView<Empty>()
private let badgeForeground: SimpleLayer
let badgeIcon: UIImageView
private let badgeIcon: UIImageView
private var disappearingBadgeIcon: UIImageView?
private let badgeLabel = ComponentView<Empty>()
private let suffixLabel = ComponentView<Empty>()
@ -68,11 +69,10 @@ final class ProfileLevelRatingBarBadge: Component {
self.badgeView.alpha = 0.0
self.badgeView.layer.anchorPoint = CGPoint()
self.badgeShapeLayer.fillColor = UIColor.white.cgColor
self.badgeShapeLayer.transform = CATransform3DMakeScale(1.0, -1.0, 1.0)
self.badgeShapeView = UIImageView()
self.badgeMaskView = UIView()
self.badgeMaskView.layer.addSublayer(self.badgeShapeLayer)
self.badgeMaskView.addSubview(self.badgeShapeView)
self.badgeView.mask = self.badgeMaskView
self.badgeForeground = SimpleLayer()
@ -108,17 +108,49 @@ final class ProfileLevelRatingBarBadge: Component {
self.isUpdating = false
}
if self.component == nil {
self.badgeIcon.image = UIImage(bundleImageName: "Peer Info/ProfileLevelProgressIcon")?.withRenderingMode(.alwaysTemplate)
var labelsTransition = transition
if let hint = transition.userData(TransitionHint.self), hint.animateText {
labelsTransition = .spring(duration: 0.5)
}
let previousComponent = self.component
if previousComponent == nil || (previousComponent?.title == "") != (component.title == "") {
if !labelsTransition.animation.isImmediate, self.badgeIcon.image != nil {
if let disappearingBadgeIcon = self.disappearingBadgeIcon {
self.disappearingBadgeIcon = nil
disappearingBadgeIcon.removeFromSuperview()
}
let disappearingBadgeIcon = UIImageView()
disappearingBadgeIcon.contentMode = self.badgeIcon.contentMode
disappearingBadgeIcon.frame = self.badgeIcon.frame
disappearingBadgeIcon.image = self.badgeIcon.image
disappearingBadgeIcon.tintColor = self.badgeIcon.tintColor
self.badgeView.insertSubview(disappearingBadgeIcon, aboveSubview: self.badgeIcon)
labelsTransition.setScale(view: disappearingBadgeIcon, scale: 0.001)
labelsTransition.setAlpha(view: disappearingBadgeIcon, alpha: 0.0, completion: { [weak self, weak disappearingBadgeIcon] _ in
guard let self, let disappearingBadgeIcon else {
return
}
disappearingBadgeIcon.removeFromSuperview()
if self.disappearingBadgeIcon === disappearingBadgeIcon {
self.disappearingBadgeIcon = nil
}
})
labelsTransition.animateScale(view: self.badgeIcon, from: 0.001, to: 1.0)
labelsTransition.animateAlpha(view: self.badgeIcon, from: 0.0, to: 1.0)
}
if component.title.isEmpty {
self.badgeIcon.image = UIImage(bundleImageName: "Peer Info/ProfileLevelWarningIcon")?.withRenderingMode(.alwaysTemplate)
} else {
self.badgeIcon.image = UIImage(bundleImageName: "Peer Info/ProfileLevelProgressIcon")?.withRenderingMode(.alwaysTemplate)
}
}
self.component = component
self.badgeIcon.tintColor = component.theme.list.itemCheckColors.foregroundColor
var labelsTransition = transition
if let hint = transition.userData(TransitionHint.self), hint.animateText {
labelsTransition = .spring(duration: 0.4)
}
self.disappearingBadgeIcon?.tintColor = component.theme.list.itemCheckColors.foregroundColor
let badgeLabelSize = self.badgeLabel.update(
transition: labelsTransition,
@ -150,7 +182,12 @@ final class ProfileLevelRatingBarBadge: Component {
containerSize: CGSize(width: 300.0, height: 100.0)
)
var badgeWidth: CGFloat = badgeLabelSize.width + 3.0 + 60.0
var badgeWidth: CGFloat = 0.0
if !component.title.isEmpty {
badgeWidth += badgeLabelSize.width + 3.0 + 60.0
} else {
badgeWidth += 78.0
}
if component.suffix != nil {
badgeWidth += badgeSuffixSize.width + badgeSuffixSpacing
}
@ -158,13 +195,21 @@ final class ProfileLevelRatingBarBadge: Component {
let badgeSize = CGSize(width: badgeWidth, height: 48.0)
let badgeFullSize = CGSize(width: badgeWidth, height: 48.0 + 12.0)
self.badgeMaskView.frame = CGRect(origin: .zero, size: badgeFullSize)
self.badgeShapeLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -4.0), size: badgeFullSize)
self.badgeView.bounds = CGRect(origin: .zero, size: badgeFullSize)
self.badgeForeground.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 600.0, height: badgeFullSize.height + 10.0))
self.badgeIcon.frame = CGRect(x: 13.0, y: 8.0, width: 30.0, height: 30.0)
let badgeIconFrame: CGRect
if !component.title.isEmpty {
badgeIconFrame = CGRect(x: 13.0, y: 8.0, width: 30.0, height: 30.0)
} else {
badgeIconFrame = CGRect(x: floor((badgeWidth - 30.0) * 0.5), y: 8.0, width: 30.0, height: 30.0)
}
labelsTransition.setFrame(view: self.badgeIcon, frame: badgeIconFrame)
if let disappearingBadgeIcon = self.disappearingBadgeIcon {
labelsTransition.setFrame(view: disappearingBadgeIcon, frame: badgeIconFrame)
}
self.badgeView.alpha = 1.0
@ -194,77 +239,63 @@ final class ProfileLevelRatingBarBadge: Component {
if self.previousAvailableSize != availableSize {
self.previousAvailableSize = availableSize
let activeColors: [UIColor] = [
component.theme.list.itemCheckColors.fillColor,
component.theme.list.itemCheckColors.fillColor
]
var locations: [CGFloat] = []
let delta = 1.0 / CGFloat(activeColors.count - 1)
for i in 0 ..< activeColors.count {
locations.append(delta * CGFloat(i))
}
let gradient = generateGradientImage(size: CGSize(width: 200.0, height: 60.0), colors: activeColors, locations: locations, direction: .horizontal)
self.badgeForeground.contentsGravity = .resizeAspectFill
self.badgeForeground.contents = gradient?.cgImage
self.setupGradientAnimations()
}
return size
}
func adjustTail(size: CGSize, overflowWidth: CGFloat, transition: ComponentTransition) {
guard let component else {
return
func updateColors(background: UIColor) {
self.badgeForeground.backgroundColor = background.cgColor
}
func adjustTail(size: CGSize, tailOffset: CGFloat, transition: ComponentTransition) {
if self.badgeShapeView.image == nil {
self.badgeShapeView.image = generateStretchableFilledCircleImage(diameter: 48.0, color: UIColor.white)
}
let _ = component
self.badgeShapeView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: 48.0))
var tailPosition = size.width * 0.5
tailPosition += overflowWidth
tailPosition = max(36.0, min(size.width - 36.0, tailPosition))
let tailPositionFraction = tailPosition / size.width
transition.setShapeLayerPath(layer: self.badgeShapeLayer, path: generateRoundedRectWithTailPath(rectSize: size, tailPosition: tailPositionFraction, transformTail: false).cgPath)
let badgeShapeSize = CGSize(width: 128, height: 128)
let badgeShapeSize = CGSize(width: 78, height: 60)
let _ = self.badgeShapeAnimation.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "badge_with_tail"),
color: .red,//component.theme.list.itemCheckColors.fillColor,
color: .white,
placeholderColor: nil,
startingPosition: .begin,
size: badgeShapeSize,
renderingScale: nil,
renderingScale: UIScreenScale,
loop: false,
playOnce: nil
)),
environment: {},
containerSize: badgeShapeSize
)
if let badgeShapeAnimationView = self.badgeShapeAnimation.view as? LottieComponent.View, !"".isEmpty {
if let badgeShapeAnimationView = self.badgeShapeAnimation.view as? LottieComponent.View {
if badgeShapeAnimationView.superview == nil {
badgeShapeAnimationView.layer.anchorPoint = CGPoint()
self.addSubview(badgeShapeAnimationView)
self.badgeMaskView.addSubview(badgeShapeAnimationView)
}
let transition: ComponentTransition = .immediate
var shapeFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: badgeShapeSize)
let badgeShapeWidth = badgeShapeSize.width
let midFrame = 359 / 2
if tailOffset < badgeShapeWidth * 0.5 {
let frameIndex = Int(floor(CGFloat(midFrame) * tailOffset / (badgeShapeWidth * 0.5)))
badgeShapeAnimationView.setFrameIndex(index: frameIndex)
} else if tailOffset >= size.width - badgeShapeWidth * 0.5 {
let endOffset = tailOffset - (size.width - badgeShapeWidth * 0.5)
let frameIndex = midFrame + Int(floor(CGFloat(359 - midFrame) * endOffset / (badgeShapeWidth * 0.5)))
badgeShapeAnimationView.setFrameIndex(index: frameIndex)
shapeFrame.origin.x = size.width - badgeShapeWidth
} else {
badgeShapeAnimationView.setFrameIndex(index: midFrame)
shapeFrame.origin.x = tailOffset - badgeShapeWidth * 0.5
}
let shapeFrame = CGRect(origin: CGPoint(x: 0.0, y: -10.0), size: badgeShapeSize)
badgeShapeAnimationView.center = shapeFrame.origin
badgeShapeAnimationView.bounds = CGRect(origin: CGPoint(), size: shapeFrame.size)
let scaleFactor: CGFloat = 144.0 / (946.0 / 4.0)
transition.setScale(view: badgeShapeAnimationView, scale: scaleFactor)
let badgeShapeWidth = floor(shapeFrame.width * scaleFactor)
let badgeShapeOffset = -overflowWidth / badgeShapeWidth
let _ = badgeShapeOffset
//badgeShapeAnimationView.setFrameIndex(index: 0)
}
//let transition: ContainedViewLayoutTransition = .immediate
@ -275,38 +306,6 @@ final class ProfileLevelRatingBarBadge: Component {
let transition: ContainedViewLayoutTransition = .immediate
transition.updateTransformRotation(view: self.badgeView, angle: angle)
}
private func setupGradientAnimations() {
guard let _ = self.component else {
return
}
if let _ = self.badgeForeground.animation(forKey: "movement") {
} else {
CATransaction.begin()
let badgePreviousValue = self.badgeForeground.position.x
let badgeNewValue: CGFloat
if self.badgeForeground.position.x == -300.0 {
badgeNewValue = 0.0
} else {
badgeNewValue = -300.0
}
self.badgeForeground.position = CGPoint(x: badgeNewValue, y: 0.0)
let badgeAnimation = CABasicAnimation(keyPath: "position.x")
badgeAnimation.duration = 4.5
badgeAnimation.fromValue = badgePreviousValue
badgeAnimation.toValue = badgeNewValue
badgeAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
CATransaction.setCompletionBlock { [weak self] in
self?.setupGradientAnimations()
}
self.badgeForeground.add(badgeAnimation, forKey: "movement")
CATransaction.commit()
}
}
}
func makeView() -> View {

View File

@ -6,7 +6,6 @@ import ComponentFlow
import MultilineTextComponent
import BundleIconComponent
import HierarchyTrackingLayer
import AnimatedTextComponent
final class ProfileLevelRatingBarComponent: Component {
final class TransitionHint {
@ -69,20 +68,33 @@ final class ProfileLevelRatingBarComponent: Component {
}
private final class AnimationState {
enum Wraparound {
case left
case right
}
let fromLevel: Int
let toLevel: Int
let fromLeftLabelText: String
let fromRightLabelText: String
let fromValue: CGFloat
let toValue: CGFloat
let fromBadgeSize: CGSize
let startTime: Double
let duration: Double
let isWraparound: Bool
let wraparound: Wraparound?
init(fromValue: CGFloat, toValue: CGFloat, fromBadgeSize: CGSize, startTime: Double, duration: Double, isWraparound: Bool) {
init(fromLevel: Int, toLevel: Int, fromLeftLabelText: String, fromRightLabelText: String, fromValue: CGFloat, toValue: CGFloat, fromBadgeSize: CGSize, startTime: Double, duration: Double, wraparound: Wraparound?) {
self.fromLevel = fromLevel
self.toLevel = toLevel
self.fromLeftLabelText = fromLeftLabelText
self.fromRightLabelText = fromRightLabelText
self.fromValue = fromValue
self.toValue = toValue
self.fromBadgeSize = fromBadgeSize
self.startTime = startTime
self.duration = duration
self.isWraparound = isWraparound
self.wraparound = wraparound
}
func timeFraction(at timestamp: Double) -> CGFloat {
@ -91,8 +103,30 @@ final class ProfileLevelRatingBarComponent: Component {
return fraction
}
func stepFraction(at timestamp: Double) -> (step: Int, fraction: CGFloat) {
if self.wraparound != nil {
var t = self.timeFraction(at: timestamp)
t = bezierPoint(0.6, 0.0, 0.4, 1.0, t)
if t < 0.5 {
let vt = t / 0.5
return (0, vt)
} else {
let vt = (t - 0.5) / 0.5
return (1, vt)
}
} else {
let t = self.timeFraction(at: timestamp)
return (0, listViewAnimationCurveSystem(t))
}
}
func fraction(at timestamp: Double) -> CGFloat {
return listViewAnimationCurveSystem(self.timeFraction(at: timestamp))
let t = self.timeFraction(at: timestamp)
if self.wraparound != nil {
return listViewAnimationCurveEaseInOut(t)
} else {
return listViewAnimationCurveSystem(t)
}
}
func value(at timestamp: Double) -> CGFloat {
@ -100,14 +134,12 @@ final class ProfileLevelRatingBarComponent: Component {
return (1.0 - fraction) * self.fromValue + fraction * self.toValue
}
func wrapAroundValue(at timestamp: Double, topValue: CGFloat) -> CGFloat {
let fraction = self.fraction(at: timestamp)
if fraction <= 0.5 {
let halfFraction = fraction / 0.5
return (1.0 - halfFraction) * self.fromValue + halfFraction * topValue
func wrapAroundValue(at timestamp: Double, bottomValue: CGFloat, topValue: CGFloat) -> CGFloat {
let (step, fraction) = self.stepFraction(at: timestamp)
if step == 0 {
return (1.0 - fraction) * self.fromValue + fraction * topValue
} else {
let halfFraction = (fraction - 0.5) / 0.5
return halfFraction * self.toValue
return (1.0 - fraction) * bottomValue + fraction * self.toValue
}
}
@ -123,6 +155,7 @@ final class ProfileLevelRatingBarComponent: Component {
final class View: UIView {
private let barBackground: UIImageView
private let backgroundClippingContainer: UIView
private let foregroundBarClippingContainer: UIView
private let foregroundClippingContainer: UIView
private let barForeground: UIImageView
@ -139,13 +172,29 @@ final class ProfileLevelRatingBarComponent: Component {
private var hierarchyTracker: HierarchyTrackingLayer?
private var animationLink: SharedDisplayLinkDriver.Link?
private var badgePhysicsLink: SharedDisplayLinkDriver.Link?
private var animationState: AnimationState?
private var previousAnimationTimestamp: Double?
private var previousAnimationTimeFraction: CGFloat?
private var animationDeltaTime: Double?
private var animationIsMovingOverStep: Bool = false
private var badgeAngularSpeed: CGFloat = 0.0
private var badgeScale: CGFloat = 1.0
private var badgeAngle: CGFloat = 0.0
private var previousPhysicsTimestamp: Double?
private var testFraction: CGFloat?
private var startTestFraction: CGFloat?
override init(frame: CGRect) {
self.barBackground = UIImageView()
self.backgroundClippingContainer = UIView()
self.backgroundClippingContainer.clipsToBounds = true
self.foregroundBarClippingContainer = UIView()
self.foregroundBarClippingContainer.clipsToBounds = true
self.foregroundClippingContainer = UIView()
self.foregroundClippingContainer.clipsToBounds = true
self.barForeground = UIImageView()
@ -161,7 +210,28 @@ final class ProfileLevelRatingBarComponent: Component {
return
}
self.updateAnimations()
if value {
if self.badgePhysicsLink == nil {
let badgePhysicsLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in
guard let self else {
return
}
self.updateBadgePhysics()
})
self.badgePhysicsLink = badgePhysicsLink
}
} else {
if let badgePhysicsLink = self.badgePhysicsLink {
self.badgePhysicsLink = nil
badgePhysicsLink.invalidate()
}
}
}
#if DEBUG
self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.onPanGesture(_:))))
#endif
}
required init?(coder: NSCoder) {
@ -171,7 +241,40 @@ final class ProfileLevelRatingBarComponent: Component {
deinit {
}
@objc private func onPanGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began, .changed:
if self.testFraction == nil {
self.testFraction = self.component?.value
}
if self.startTestFraction == nil {
if let testFraction = self.testFraction {
self.startTestFraction = testFraction
}
}
if let startTestFraction = self.startTestFraction {
let x = recognizer.translation(in: self).x
var value: CGFloat = startTestFraction + x / self.bounds.width
value = max(0.0, min(1.0, value))
self.testFraction = value
self.state?.updated(transition: .immediate, isLocal: true)
}
case .ended, .cancelled:
self.startTestFraction = nil
default:
break
}
}
private func updateAnimations() {
let timestamp = CACurrentMediaTime()
let deltaTime: CGFloat
if let previousAnimationTimestamp = self.previousAnimationTimestamp {
deltaTime = min(0.2, timestamp - previousAnimationTimestamp)
} else {
deltaTime = 1.0 / 60.0
}
if let hierarchyTracker = self.hierarchyTracker, hierarchyTracker.isInHierarchy {
if self.animationState != nil {
if self.animationLink == nil {
@ -194,15 +297,104 @@ final class ProfileLevelRatingBarComponent: Component {
}
if let animationState = self.animationState {
if animationState.timeFraction(at: CACurrentMediaTime()) >= 1.0 {
let timeFraction = animationState.timeFraction(at: timestamp)
if timeFraction >= 1.0 {
self.animationState = nil
self.updateAnimations()
return
} else {
if let previousAnimationTimeFraction = self.previousAnimationTimeFraction {
if previousAnimationTimeFraction < 0.5 && timeFraction >= 0.5 {
self.animationIsMovingOverStep = true
}
}
self.previousAnimationTimeFraction = timeFraction
}
} else {
self.previousAnimationTimeFraction = nil
}
self.animationDeltaTime = Double(deltaTime)
if self.animationState != nil && !self.isUpdating {
self.state?.updated(transition: .immediate, isLocal: true)
}
self.animationDeltaTime = nil
self.animationIsMovingOverStep = false
}
private func addBadgeDeltaX(value: CGFloat, deltaTime: CGFloat) {
var deltaTime = deltaTime
deltaTime /= UIView.animationDurationFactor()
let horizontalVelocity = value / deltaTime
var badgeAngle = self.badgeAngle
badgeAngle -= horizontalVelocity * 0.00005
let maxAngle: CGFloat = 0.1
if abs(badgeAngle) > maxAngle {
badgeAngle = badgeAngle < 0.0 ? -maxAngle : maxAngle
}
self.badgeAngle = badgeAngle
}
private func updateBadgePhysics() {
let timestamp = CACurrentMediaTime()
var deltaTime: CGFloat
if let previousPhysicsTimestamp = self.previousPhysicsTimestamp {
deltaTime = CGFloat(min(1.0 / 60.0, timestamp - previousPhysicsTimestamp))
} else {
deltaTime = CGFloat(1.0 / 60.0)
}
self.previousPhysicsTimestamp = timestamp
deltaTime /= UIView.animationDurationFactor()
let testSpringFriction: CGFloat = 18.5
let testSpringConstant: CGFloat = 243.0
let frictionConstant: CGFloat = testSpringFriction
let springConstant: CGFloat = testSpringConstant
let time: CGFloat = deltaTime
var badgeAngle = self.badgeAngle
// friction force = velocity * friction constant
let frictionForce = self.badgeAngularSpeed * frictionConstant
// spring force = (target point - current position) * spring constant
let springForce = -badgeAngle * springConstant
// force = spring force - friction force
let force = springForce - frictionForce
// velocity = current velocity + force * time / mass
self.badgeAngularSpeed = self.badgeAngularSpeed + force * time
// position = current position + velocity * time
badgeAngle = badgeAngle + self.badgeAngularSpeed * time
badgeAngle = badgeAngle.isNaN ? 0.0 : badgeAngle
let epsilon: CGFloat = 0.01
if abs(badgeAngle) < epsilon && abs(self.badgeAngularSpeed) < epsilon {
badgeAngle = 0.0
self.badgeAngularSpeed = 0.0
}
if abs(badgeAngle) > 0.22 {
badgeAngle = badgeAngle < 0.0 ? -0.22 : 0.22
}
if self.badgeAngle != badgeAngle {
self.badgeAngle = badgeAngle
self.updateBadgeTransform()
}
}
private func updateBadgeTransform() {
guard let badgeView = self.badge.view else {
return
}
var transform = CATransform3DIdentity
transform = CATransform3DScale(transform, self.badgeScale, self.badgeScale, 1.0)
transform = CATransform3DRotate(transform, self.badgeAngle, 0.0, 0.0, 1.0)
badgeView.layer.transform = transform
}
func update(component: ProfileLevelRatingBarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
@ -215,11 +407,31 @@ final class ProfileLevelRatingBarComponent: Component {
var labelsTransition = transition
if let previousComponent = self.component, let hint = transition.userData(TransitionHint.self), hint.animate {
labelsTransition = .spring(duration: 0.4)
labelsTransition = .spring(duration: 0.5)
var fromLevel = previousComponent.level
var fromLeftLabelText = previousComponent.leftLabel
var fromRightLabelText = previousComponent.rightLabel
let toLevel: Int = component.level
let fromValue: CGFloat
if let animationState = self.animationState {
fromValue = animationState.value(at: CACurrentMediaTime())
if let wraparound = animationState.wraparound {
let wraparoundEnd: CGFloat
switch wraparound {
case .left:
wraparoundEnd = 0.0
case .right:
wraparoundEnd = 1.0
}
if animationState.stepFraction(at: CACurrentMediaTime()).step == 0 {
fromLevel = animationState.fromLevel
fromLeftLabelText = animationState.fromLeftLabelText
fromRightLabelText = animationState.fromRightLabelText
}
fromValue = animationState.wrapAroundValue(at: CACurrentMediaTime(), bottomValue: 1.0 - wraparoundEnd, topValue: wraparoundEnd)
} else {
fromValue = animationState.value(at: CACurrentMediaTime())
}
} else {
fromValue = previousComponent.value
}
@ -229,13 +441,23 @@ final class ProfileLevelRatingBarComponent: Component {
} else {
fromBadgeSize = CGSize()
}
var wraparound: AnimationState.Wraparound?
var duration = 0.4
if previousComponent.level != component.level {
wraparound = component.level > previousComponent.level ? .right : .left
duration = 0.8
}
self.animationState = AnimationState(
fromLevel: fromLevel,
toLevel: toLevel,
fromLeftLabelText: fromLeftLabelText,
fromRightLabelText: fromRightLabelText,
fromValue: fromValue,
toValue: component.value,
fromBadgeSize: fromBadgeSize,
startTime: CACurrentMediaTime(),
duration: 0.4 * UIView.animationDurationFactor(),
isWraparound: false//previousComponent.level < component.level
duration: duration * UIView.animationDurationFactor(),
wraparound: wraparound
)
self.updateAnimations()
}
@ -248,20 +470,19 @@ final class ProfileLevelRatingBarComponent: Component {
self.barForeground.image = self.barBackground.image
}
self.barBackground.tintColor = component.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.5)
self.barForeground.tintColor = component.theme.list.itemCheckColors.fillColor
if self.barBackground.superview == nil {
self.addSubview(self.barBackground)
self.addSubview(self.backgroundClippingContainer)
self.addSubview(self.foregroundBarClippingContainer)
self.foregroundBarClippingContainer.addSubview(self.barForeground)
self.addSubview(self.foregroundClippingContainer)
self.foregroundClippingContainer.addSubview(self.barForeground)
}
let progressValue: CGFloat
if let animationState = self.animationState {
progressValue = animationState.value(at: CACurrentMediaTime())
if let testFraction = self.testFraction {
progressValue = testFraction
} else {
progressValue = component.value
}
@ -269,18 +490,84 @@ final class ProfileLevelRatingBarComponent: Component {
let barBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - barHeight), size: CGSize(width: availableSize.width, height: barHeight))
transition.setFrame(view: self.barBackground, frame: barBackgroundFrame)
let barForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
var barForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
var barApparentForegroundFrame = barForegroundFrame
if let animationState = self.animationState, animationState.isWraparound {
let progressValue = animationState.wrapAroundValue(at: CACurrentMediaTime(), topValue: 1.0)
barApparentForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
var foregroundAlpha: CGFloat = 1.0
var foregroundContentsAlpha: CGFloat = 1.0
var badgeScale: CGFloat = 1.0
var currentIsNegativeRating: Bool = component.level < 0
var leftLabelText = component.leftLabel
var rightLabelText = component.rightLabel
if let animationState = self.animationState {
if let wraparound = animationState.wraparound {
let (step, progress) = animationState.stepFraction(at: CACurrentMediaTime())
if step == 0 {
currentIsNegativeRating = animationState.fromLevel < 0
leftLabelText = animationState.fromLeftLabelText
rightLabelText = animationState.fromRightLabelText
} else {
currentIsNegativeRating = animationState.toLevel < 0
}
let wraparoundEnd: CGFloat
switch wraparound {
case .left:
wraparoundEnd = 0.0
if step == 0 {
foregroundContentsAlpha = 1.0 * (1.0 - progress)
badgeScale = 1.0 * (1.0 - progress) + 0.3 * progress
} else {
foregroundAlpha = 1.0 * progress
foregroundContentsAlpha = foregroundAlpha
badgeScale = 1.0 * progress + 0.3 * (1.0 - progress)
}
case .right:
wraparoundEnd = 1.0
if step == 0 {
foregroundAlpha = 1.0 * (1.0 - progress)
foregroundContentsAlpha = foregroundAlpha
badgeScale = 1.0 * (1.0 - progress) + 0.3 * progress
} else {
foregroundContentsAlpha = 1.0 * progress
badgeScale = 1.0 * progress + 0.3 * (1.0 - progress)
}
}
let progressValue = animationState.wrapAroundValue(at: CACurrentMediaTime(), bottomValue: 1.0 - wraparoundEnd, topValue: wraparoundEnd)
barForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
} else {
let progressValue = animationState.value(at: CACurrentMediaTime())
barForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
}
}
transition.setFrame(view: self.foregroundClippingContainer, frame: barApparentForegroundFrame)
let backgroundClippingFrame = CGRect(origin: CGPoint(x: barBackgroundFrame.minX + barApparentForegroundFrame.width, y: barBackgroundFrame.minY), size: CGSize(width: barBackgroundFrame.width - barApparentForegroundFrame.width, height: barBackgroundFrame.height))
let badgeColor: UIColor
if currentIsNegativeRating {
badgeColor = UIColor(rgb: 0xFF3B30)
} else {
badgeColor = component.theme.list.itemCheckColors.fillColor
}
self.barBackground.tintColor = component.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.5)
self.barForeground.tintColor = badgeColor
var effectiveBarForegroundFrame = barForegroundFrame
if currentIsNegativeRating {
effectiveBarForegroundFrame.size.width = barBackgroundFrame.maxX - barForegroundFrame.maxX
effectiveBarForegroundFrame.origin.x = barBackgroundFrame.maxX - effectiveBarForegroundFrame.width
}
transition.setPosition(view: self.foregroundBarClippingContainer, position: effectiveBarForegroundFrame.center)
transition.setBounds(view: self.foregroundBarClippingContainer, bounds: CGRect(origin: CGPoint(x: effectiveBarForegroundFrame.minX - barForegroundFrame.minX, y: 0.0), size: effectiveBarForegroundFrame.size))
transition.setPosition(view: self.foregroundClippingContainer, position: effectiveBarForegroundFrame.center)
transition.setBounds(view: self.foregroundClippingContainer, bounds: CGRect(origin: CGPoint(x: effectiveBarForegroundFrame.minX - barForegroundFrame.minX, y: 0.0), size: effectiveBarForegroundFrame.size))
transition.setAlpha(view: self.foregroundBarClippingContainer, alpha: foregroundAlpha)
transition.setAlpha(view: self.foregroundClippingContainer, alpha: foregroundContentsAlpha)
let backgroundClippingFrame = CGRect(origin: CGPoint(x: barBackgroundFrame.minX + barForegroundFrame.width, y: barBackgroundFrame.minY), size: CGSize(width: barBackgroundFrame.width - barForegroundFrame.width, height: barBackgroundFrame.height))
transition.setPosition(view: self.backgroundClippingContainer, position: backgroundClippingFrame.center)
transition.setBounds(view: self.backgroundClippingContainer, bounds: CGRect(origin: CGPoint(x: backgroundClippingFrame.minX - barBackgroundFrame.minX, y: 0.0), size: backgroundClippingFrame.size))
transition.setAlpha(view: self.backgroundClippingContainer, alpha: foregroundContentsAlpha)
transition.setFrame(view: self.barForeground, frame: CGRect(origin: CGPoint(), size: barBackgroundFrame.size))
@ -288,52 +575,32 @@ final class ProfileLevelRatingBarComponent: Component {
let leftLabelSize = self.backgroundLeftLabel.update(
transition: labelsTransition,
component: AnyComponent(AnimatedTextComponent(
font: labelFont,
color: component.theme.list.itemPrimaryTextColor,
items: [AnimatedTextComponent.Item(
id: AnyHashable(0),
content: .text(component.leftLabel)
)]
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: leftLabelText, font: labelFont, textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let _ = self.foregroundLeftLabel.update(
transition: labelsTransition,
component: AnyComponent(AnimatedTextComponent(
font: labelFont,
color: component.theme.list.itemCheckColors.foregroundColor,
items: [AnimatedTextComponent.Item(
id: AnyHashable(0),
content: .text(component.leftLabel)
)]
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: leftLabelText, font: labelFont, textColor: component.theme.list.itemCheckColors.foregroundColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let rightLabelSize = self.backgroundRightLabel.update(
transition: labelsTransition,
component: AnyComponent(AnimatedTextComponent(
font: labelFont,
color: component.theme.list.itemPrimaryTextColor,
items: [AnimatedTextComponent.Item(
id: AnyHashable(0),
content: .text(component.rightLabel)
)]
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: rightLabelText, font: labelFont, textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let _ = self.foregroundRightLabel.update(
transition: labelsTransition,
component: AnyComponent(AnimatedTextComponent(
font: labelFont,
color: component.theme.list.itemCheckColors.foregroundColor,
items: [AnimatedTextComponent.Item(
id: AnyHashable(0),
content: .text(component.rightLabel)
)]
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: rightLabelText, font: labelFont, textColor: component.theme.list.itemCheckColors.foregroundColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
@ -379,8 +646,8 @@ final class ProfileLevelRatingBarComponent: Component {
transition: transition.withUserData(ProfileLevelRatingBarBadge.TransitionHint(animateText: !labelsTransition.animation.isImmediate)),
component: AnyComponent(ProfileLevelRatingBarBadge(
theme: component.theme,
title: "\(component.badgeValue)",
suffix: component.badgeTotal
title: component.level < 0 ? "" : "\(component.badgeValue)",
suffix: component.level < 0 ? nil : component.badgeTotal
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 200.0)
@ -412,9 +679,24 @@ final class ProfileLevelRatingBarComponent: Component {
}
badgeFrame.origin.x += badgeOverflowWidth
badgeView.frame = badgeFrame
let badgeTailOffset = (barBackgroundFrame.minX + barForegroundFrame.width) - badgeFrame.minX
let badgePosition = CGPoint(x: badgeFrame.minX + badgeTailOffset, y: badgeFrame.maxY)
badgeView.adjustTail(size: apparentBadgeSize, overflowWidth: -badgeOverflowWidth, transition: transition)
if let animationDeltaTime = self.animationDeltaTime, self.animationState != nil, !self.animationIsMovingOverStep {
let previousX = badgeView.center.x
self.addBadgeDeltaX(value: badgePosition.x - previousX, deltaTime: animationDeltaTime)
}
badgeView.center = badgePosition
badgeView.bounds = CGRect(origin: CGPoint(), size: badgeFrame.size)
transition.setAnchorPoint(layer: badgeView.layer, anchorPoint: CGPoint(x: max(0.0, min(1.0, badgeTailOffset / badgeFrame.width)), y: 1.0))
badgeView.updateColors(background: badgeColor)
badgeView.adjustTail(size: apparentBadgeSize, tailOffset: badgeTailOffset, transition: transition)
transition.setAlpha(view: badgeView, alpha: foregroundContentsAlpha)
self.badgeScale = badgeScale
self.updateBadgeTransform()
}
return availableSize

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "badratingprofile.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "badratingslider.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because one or more lines are too long