From 42f43bf767029b6d66a642503791dee91bee89e8 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Tue, 4 Apr 2023 15:31:56 +0400 Subject: [PATCH] Folder improvements --- submodules/CheckNode/Sources/CheckNode.swift | 310 ++++++++---------- .../Display/Source/CAAnimationUtils.swift | 4 + .../Sources/PremiumLimitScreen.swift | 22 +- .../Sources/Network/FetchV2.swift | 9 + .../Components/AnimatedCounterComponent/BUILD | 19 ++ .../Sources/AnimatedCounterComponent.swift | 279 ++++++++++++++++ .../ChatFolderLinkPreviewScreen/BUILD | 2 + .../Sources/ChatFolderLinkPreviewScreen.swift | 41 +-- .../Components/PlainButtonComponent/BUILD | 19 ++ .../Sources/PlainButtonComponent.swift | 154 +++++++++ 10 files changed, 664 insertions(+), 195 deletions(-) create mode 100644 submodules/TelegramUI/Components/AnimatedCounterComponent/BUILD create mode 100644 submodules/TelegramUI/Components/AnimatedCounterComponent/Sources/AnimatedCounterComponent.swift create mode 100644 submodules/TelegramUI/Components/PlainButtonComponent/BUILD create mode 100644 submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index 2776218960..03259a5ed9 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -127,6 +127,28 @@ public class CheckNode: ASDisplayNode { animation.timingFunction = CAMediaTimingFunction(name: selected ? CAMediaTimingFunctionName.easeOut : CAMediaTimingFunctionName.easeIn) animation.duration = selected ? 0.21 : 0.15 self.pop_add(animation, forKey: "progress") + + if selected { + self.layer.animateScale(from: 1.0, to: 0.9, duration: 0.08, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + self.layer.animateScale(from: 0.9, to: 1.1, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + + self.layer.animateScale(from: 1.1, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + }) + }) + } else { + self.layer.animateScale(from: 1.0, to: 0.9, duration: 0.08, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + self.layer.animateScale(from: 0.9, to: 1.0, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) + }) + } } else { self.pop_removeAllAnimations() self.animatingOut = false @@ -152,108 +174,11 @@ public class CheckNode: ASDisplayNode { } if let parameters = parameters as? CheckNodeParameters { - let center = CGPoint(x: bounds.width / 2.0, y: bounds.width / 2.0) - - var borderWidth: CGFloat = 1.0 + UIScreenPixel - if parameters.theme.hasInset { - borderWidth = 1.5 - } - if let customBorderWidth = parameters.theme.borderWidth { - borderWidth = customBorderWidth - } - - let checkWidth: CGFloat = 1.5 - - let inset: CGFloat = parameters.theme.hasInset ? 2.0 - UIScreenPixel : 0.0 - - let checkProgress = parameters.animatingOut ? 1.0 : parameters.animationProgress - let fillProgress = parameters.animatingOut ? 1.0 : min(1.0, parameters.animationProgress * 1.35) - - context.setStrokeColor(parameters.theme.borderColor.cgColor) - if parameters.theme.isDottedBorder { - context.setLineDash(phase: 0.0, lengths: [4.0, 4.0]) - } - context.setLineWidth(borderWidth) - - let maybeScaleOut = { - let animate: Bool - if case .counter = parameters.content { - animate = true - } else if parameters.animatingOut { - animate = true - } else { - animate = false - } - if animate { - context.translateBy(x: bounds.width / 2.0, y: bounds.height / 2.0) - context.scaleBy(x: parameters.animationProgress, y: parameters.animationProgress) - context.translateBy(x: -bounds.width / 2.0, y: -bounds.height / 2.0) - - context.setAlpha(parameters.animationProgress) - } - } - - let borderInset = borderWidth / 2.0 + inset - let borderProgress: CGFloat = parameters.theme.filledBorder ? fillProgress : 1.0 - let borderFrame = bounds.insetBy(dx: borderInset, dy: borderInset) - - if parameters.theme.filledBorder { - maybeScaleOut() - } - - context.saveGState() - if parameters.theme.hasShadow { - context.setShadow(offset: CGSize(), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor) - } - - context.strokeEllipse(in: borderFrame.insetBy(dx: borderFrame.width * (1.0 - borderProgress), dy: borderFrame.height * (1.0 - borderProgress))) - context.restoreGState() - - if !parameters.theme.filledBorder { - maybeScaleOut() - } - - context.setFillColor(parameters.theme.backgroundColor.cgColor) - - let fillInset = parameters.theme.overlayBorder ? borderWidth + inset : inset - let fillFrame = bounds.insetBy(dx: fillInset, dy: fillInset) - context.fillEllipse(in: fillFrame.insetBy(dx: fillFrame.width * (1.0 - fillProgress), dy: fillFrame.height * (1.0 - fillProgress))) - - switch parameters.content { - case .check: - let scale = (bounds.width - inset) / 18.0 - let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) - let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) - let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) - let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) - - if !firstSegment.isZero { - if firstSegment < 1.0 { - context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) - context.addLine(to: s) - } else { - let secondSegment = (checkProgress - 0.33) * 1.5 - context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) - context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) - context.addLine(to: s) - } - } - - context.setStrokeColor(parameters.theme.strokeColor.cgColor) - if parameters.theme.strokeColor == .clear { - context.setBlendMode(.clear) - } - context.setLineWidth(checkWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - context.setMiterLimit(10.0) - - context.strokePath() - case let .counter(number): - let string = NSAttributedString(string: "\(number)", font: Font.with(size: 16.0, design: .round, weight: .semibold), textColor: parameters.theme.strokeColor) - let stringSize = string.boundingRect(with: bounds.size, options: .usesLineFragmentOrigin, context: nil).size - string.draw(at: CGPoint(x: floorToScreenPixels((bounds.width - stringSize.width) / 2.0), y: floorToScreenPixels((bounds.height - stringSize.height) / 2.0))) - } + CheckLayer.drawContents( + context: context, + size: bounds.size, + parameters: parameters + ) } } @@ -388,6 +313,28 @@ public class CheckLayer: CALayer { animation.timingFunction = CAMediaTimingFunction(name: selected ? CAMediaTimingFunctionName.easeOut : CAMediaTimingFunctionName.easeIn) animation.duration = selected ? 0.21 : 0.15 self.pop_add(animation, forKey: "progress") + + if selected { + self.animateScale(from: 1.0, to: 0.9, duration: 0.08, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + self.animateScale(from: 0.9, to: 1.1, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + + self.animateScale(from: 1.1, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + }) + }) + } else { + self.animateScale(from: 1.0, to: 0.9, duration: 0.08, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + self.animateScale(from: 0.9, to: 1.0, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) + }) + } } else { self.pop_removeAllAnimations() self.animatingOut = false @@ -404,100 +351,125 @@ public class CheckLayer: CALayer { return } self.contents = generateImage(self.bounds.size, rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) + CheckLayer.drawContents( + context: context, + size: size, + parameters: CheckNodeParameters(theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) + ) + })?.cgImage + } + + fileprivate static func drawContents(context: CGContext, size: CGSize, parameters: CheckNodeParameters) { + context.clear(CGRect(origin: CGPoint(), size: size)) - let parameters = CheckNodeParameters(theme: self.theme, content: self.content, animationProgress: self.animationProgress, selected: self.selected, animatingOut: self.animatingOut) + let center = CGPoint(x: size.width / 2.0, y: size.width / 2.0) - let center = CGPoint(x: bounds.width / 2.0, y: bounds.width / 2.0) + var borderWidth: CGFloat = 1.0 + UIScreenPixel + if parameters.theme.hasInset { + borderWidth = 1.5 + } + if let customBorderWidth = parameters.theme.borderWidth { + borderWidth = customBorderWidth + } - var borderWidth: CGFloat = 1.0 + UIScreenPixel - if parameters.theme.hasInset { - borderWidth = 1.5 - } - if let customBorderWidth = parameters.theme.borderWidth { - borderWidth = customBorderWidth + let checkWidth: CGFloat = 1.5 + + let inset: CGFloat = parameters.theme.hasInset ? 2.0 - UIScreenPixel : 0.0 + + let checkProgress: CGFloat + + context.setStrokeColor(parameters.theme.borderColor.cgColor) + context.setLineWidth(borderWidth) + + let maybeScaleOut = { + if parameters.animatingOut { + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: parameters.animationProgress, y: parameters.animationProgress) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + + context.setAlpha(parameters.animationProgress) } + } - let checkWidth: CGFloat = 1.5 - - let inset: CGFloat = parameters.theme.hasInset ? 2.0 - UIScreenPixel : 0.0 - - let checkProgress = parameters.animatingOut ? 1.0 : parameters.animationProgress + if !parameters.theme.filledBorder { + checkProgress = parameters.animationProgress + + let fillProgress: CGFloat = parameters.animationProgress + + context.setFillColor(parameters.theme.backgroundColor.mixedWith(parameters.theme.borderColor, alpha: 1.0 - fillProgress).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + let innerDiameter: CGFloat = (fillProgress * 0.0) + (1.0 - fillProgress) * (size.width - borderWidth * 2.0) + + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - innerDiameter) * 0.5, y: (size.height - innerDiameter) * 0.5), size: CGSize(width: innerDiameter, height: innerDiameter))) + context.setBlendMode(.normal) + } else { + checkProgress = parameters.animatingOut ? 1.0 : parameters.animationProgress + let fillProgress = parameters.animatingOut ? 1.0 : min(1.0, parameters.animationProgress * 1.35) - - context.setStrokeColor(parameters.theme.borderColor.cgColor) - context.setLineWidth(borderWidth) - - let maybeScaleOut = { - if parameters.animatingOut { - context.translateBy(x: size.width / 2.0, y: size.height / 2.0) - context.scaleBy(x: parameters.animationProgress, y: parameters.animationProgress) - context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) - - context.setAlpha(parameters.animationProgress) - } - } - + let borderInset = borderWidth / 2.0 + inset let borderProgress: CGFloat = parameters.theme.filledBorder ? fillProgress : 1.0 - let borderFrame = bounds.insetBy(dx: borderInset, dy: borderInset) - + let borderFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: borderInset, dy: borderInset) + if parameters.theme.filledBorder { maybeScaleOut() } - + context.saveGState() if parameters.theme.hasShadow { context.setShadow(offset: CGSize(), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor) } - + context.strokeEllipse(in: borderFrame.insetBy(dx: borderFrame.width * (1.0 - borderProgress), dy: borderFrame.height * (1.0 - borderProgress))) context.restoreGState() - + if !parameters.theme.filledBorder { maybeScaleOut() } - + context.setFillColor(parameters.theme.backgroundColor.cgColor) - + let fillInset = parameters.theme.overlayBorder ? borderWidth + inset : inset - let fillFrame = bounds.insetBy(dx: fillInset, dy: fillInset) + let fillFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: fillInset, dy: fillInset) context.fillEllipse(in: fillFrame.insetBy(dx: fillFrame.width * (1.0 - fillProgress), dy: fillFrame.height * (1.0 - fillProgress))) + } - switch parameters.content { - case .check: - let scale = (bounds.width - inset) / 18.0 - let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) - let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) - let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) - let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) + switch parameters.content { + case .check: + let scale = (size.width - inset) / 18.0 + let firstSegment: CGFloat = max(0.0, min(1.0, checkProgress * 3.0)) + let s = CGPoint(x: center.x - (4.0 - 0.3333) * scale, y: center.y + 0.5 * scale) + let p1 = CGPoint(x: 2.5 * scale, y: 3.0 * scale) + let p2 = CGPoint(x: 4.6667 * scale, y: -6.0 * scale) - if !firstSegment.isZero { - if firstSegment < 1.0 { - context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) - context.addLine(to: s) - } else { - let secondSegment = (checkProgress - 0.33) * 1.5 - context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) - context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) - context.addLine(to: s) - } + if !firstSegment.isZero { + if firstSegment < 1.0 { + context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment)) + context.addLine(to: s) + } else { + let secondSegment = (checkProgress - 0.33) * 1.5 + context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment)) + context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y)) + context.addLine(to: s) } + } - context.setStrokeColor(parameters.theme.strokeColor.cgColor) - if parameters.theme.strokeColor == .clear { - context.setBlendMode(.clear) - } - context.setLineWidth(checkWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - context.setMiterLimit(10.0) + context.setStrokeColor(parameters.theme.strokeColor.cgColor) + if parameters.theme.strokeColor == .clear { + context.setBlendMode(.clear) + } + context.setLineWidth(checkWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setMiterLimit(10.0) - context.strokePath() - case let .counter(number): - let text = NSAttributedString(string: "\(number)", font: Font.with(size: 16.0, design: .round, weight: .regular, traits: []), textColor: parameters.theme.strokeColor) - text.draw(at: CGPoint()) - } - })?.cgImage + context.strokePath() + case let .counter(number): + let text = NSAttributedString(string: "\(number)", font: Font.with(size: 16.0, design: .round, weight: .regular, traits: []), textColor: parameters.theme.strokeColor) + text.draw(at: CGPoint()) + } } } diff --git a/submodules/Display/Source/CAAnimationUtils.swift b/submodules/Display/Source/CAAnimationUtils.swift index 698abec787..ac53fb66ca 100644 --- a/submodules/Display/Source/CAAnimationUtils.swift +++ b/submodules/Display/Source/CAAnimationUtils.swift @@ -365,6 +365,10 @@ public extension CALayer { func animateScale(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "transform.scale", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } + + func animateSublayerScale(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { + self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "sublayerTransform.scale", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) + } func animateScaleX(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "transform.scale.x", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: completion) diff --git a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift index 6eff33fb93..58ff0a2a17 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift @@ -770,8 +770,12 @@ private final class LimitSheetContent: CombinedComponent { string = component.count >= premiumLimit ? strings.Premium_MaxFoldersCountFinalText("\(premiumLimit)").string : strings.Premium_MaxFoldersCountText("\(limit)", "\(premiumLimit)").string defaultValue = component.count > limit ? "\(limit)" : "" premiumValue = component.count >= premiumLimit ? "" : "\(premiumLimit)" - badgePosition = CGFloat(component.count) / CGFloat(premiumLimit) - badgeGraphPosition = badgePosition + if component.count >= premiumLimit { + badgeGraphPosition = max(0.15, CGFloat(limit) / CGFloat(premiumLimit)) + } else { + badgeGraphPosition = max(0.15, CGFloat(component.count) / CGFloat(premiumLimit)) + } + badgePosition = max(0.15, CGFloat(component.count) / CGFloat(premiumLimit)) if !state.isPremium && badgePosition > 0.5 { string = strings.Premium_MaxFoldersCountText("\(limit)", "\(premiumLimit)").string @@ -811,11 +815,11 @@ private final class LimitSheetContent: CombinedComponent { defaultValue = count > limit ? "\(limit)" : "" premiumValue = count >= premiumLimit ? "" : "\(premiumLimit)" if count >= premiumLimit { - badgeGraphPosition = max(0.1, CGFloat(limit) / CGFloat(premiumLimit)) + badgeGraphPosition = max(0.15, CGFloat(limit) / CGFloat(premiumLimit)) } else { - badgeGraphPosition = max(0.1, CGFloat(count) / CGFloat(premiumLimit)) + badgeGraphPosition = max(0.15, CGFloat(count) / CGFloat(premiumLimit)) } - badgePosition = max(0.1, CGFloat(count) / CGFloat(premiumLimit)) + badgePosition = max(0.15, CGFloat(count) / CGFloat(premiumLimit)) if isPremiumDisabled { badgeText = "\(limit)" @@ -829,8 +833,12 @@ private final class LimitSheetContent: CombinedComponent { string = component.count >= premiumLimit ? strings.Premium_MaxSharedFolderMembershipFinalText("\(premiumLimit)").string : strings.Premium_MaxSharedFolderMembershipText("\(limit)", "\(premiumLimit)").string defaultValue = component.count > limit ? "\(limit)" : "" premiumValue = component.count >= premiumLimit ? "" : "\(premiumLimit)" - badgePosition = CGFloat(component.count) / CGFloat(premiumLimit) - badgeGraphPosition = badgePosition + if component.count >= premiumLimit { + badgeGraphPosition = max(0.15, CGFloat(limit) / CGFloat(premiumLimit)) + } else { + badgeGraphPosition = max(0.15, CGFloat(component.count) / CGFloat(premiumLimit)) + } + badgePosition = max(0.15, CGFloat(component.count) / CGFloat(premiumLimit)) if isPremiumDisabled { badgeText = "\(limit)" diff --git a/submodules/TelegramCore/Sources/Network/FetchV2.swift b/submodules/TelegramCore/Sources/Network/FetchV2.swift index 37136a08d9..e4a05c99f8 100644 --- a/submodules/TelegramCore/Sources/Network/FetchV2.swift +++ b/submodules/TelegramCore/Sources/Network/FetchV2.swift @@ -641,10 +641,13 @@ private final class FetchImpl { isComplete = true let resultingSize = fetchRange.lowerBound + actualLength if let currentKnownSize = self.knownSize { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): setting known size to min(\(currentKnownSize), \(resultingSize)) = \(min(currentKnownSize, resultingSize))") self.knownSize = min(currentKnownSize, resultingSize) } else { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): setting known size to \(resultingSize)") self.knownSize = resultingSize } + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): reporting resource size \(fetchRange.lowerBound + actualLength)") self.onNext(.resourceSizeUpdated(fetchRange.lowerBound + actualLength)) } @@ -662,15 +665,21 @@ private final class FetchImpl { } else { actualData = Data() } + + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): extracting aligned part \(partRange) (\(fetchRange)): \(actualData.count)") } if !actualData.isEmpty { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): emitting data part \(partRange) (aligned as \(fetchRange)): \(actualData.count), isComplete: \(isComplete)") + self.onNext(.dataPart( resourceOffset: partRange.lowerBound, data: actualData, range: 0 ..< Int64(actualData.count), complete: isComplete )) + } else { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): not emitting data part \(partRange) (aligned as \(fetchRange))") } case let .cdnRedirect(cdnData): self.state = .fetching(FetchImpl.FetchingState( diff --git a/submodules/TelegramUI/Components/AnimatedCounterComponent/BUILD b/submodules/TelegramUI/Components/AnimatedCounterComponent/BUILD new file mode 100644 index 0000000000..d62d5882a6 --- /dev/null +++ b/submodules/TelegramUI/Components/AnimatedCounterComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "AnimatedCounterComponent", + module_name = "AnimatedCounterComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/AnimatedCounterComponent/Sources/AnimatedCounterComponent.swift b/submodules/TelegramUI/Components/AnimatedCounterComponent/Sources/AnimatedCounterComponent.swift new file mode 100644 index 0000000000..8f77f6976a --- /dev/null +++ b/submodules/TelegramUI/Components/AnimatedCounterComponent/Sources/AnimatedCounterComponent.swift @@ -0,0 +1,279 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +final class AnimatedCounterItemComponent: Component { + public let font: UIFont + public let color: UIColor + public let text: String + public let numericValue: Int + public let alignment: CGFloat + + public init( + font: UIFont, + color: UIColor, + text: String, + numericValue: Int, + alignment: CGFloat + ) { + self.font = font + self.color = color + self.text = text + self.numericValue = numericValue + self.alignment = alignment + } + + public static func ==(lhs: AnimatedCounterItemComponent, rhs: AnimatedCounterItemComponent) -> Bool { + if lhs.font != rhs.font { + return false + } + if lhs.color != rhs.color { + return false + } + if lhs.text != rhs.text { + return false + } + if lhs.numericValue != rhs.numericValue { + return false + } + if lhs.alignment != rhs.alignment { + return false + } + return true + } + + public final class View: UIView { + private let contentView: UIImageView + + private var component: AnimatedCounterItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.contentView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.contentView) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func update(component: AnimatedCounterItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let previousNumericValue = self.component?.numericValue + + self.component = component + self.state = state + + let text = NSAttributedString(string: component.text, font: component.font, textColor: component.color) + let textBounds = text.boundingRect(with: availableSize, options: [.usesLineFragmentOrigin], context: nil) + let size = CGSize(width: ceil(textBounds.width), height: ceil(textBounds.height)) + + let previousContentImage = self.contentView.image + let previousContentFrame = self.contentView.frame + + self.contentView.image = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + + text.draw(at: textBounds.origin) + + UIGraphicsPopContext() + }) + self.contentView.frame = CGRect(origin: CGPoint(), size: size) + + if !transition.animation.isImmediate, let previousContentImage, !previousContentFrame.isEmpty, let previousNumericValue, previousNumericValue != component.numericValue { + let previousContentView = UIImageView() + previousContentView.image = previousContentImage + previousContentView.frame = CGRect(origin: CGPoint(x: size.width * component.alignment - previousContentFrame.width * component.alignment, y: previousContentFrame.minY), size: previousContentFrame.size) + self.addSubview(previousContentView) + + let offsetY: CGFloat = size.height * 0.6 * (previousNumericValue < component.numericValue ? -1.0 : 1.0) + + let subTransition = Transition(animation: .curve(duration: 0.16, curve: .easeInOut)) + + subTransition.animatePosition(view: self.contentView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) + subTransition.animateAlpha(view: self.contentView, from: 0.0, to: 1.0) + + subTransition.setPosition(view: previousContentView, position: CGPoint(x: previousContentView.layer.position.x, y: previousContentView.layer.position.y - offsetY)) + subTransition.setAlpha(view: previousContentView, alpha: 0.0, completion: { [weak previousContentView] _ in + previousContentView?.removeFromSuperview() + }) + } + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + + +public final class AnimatedCounterComponent: Component { + public enum Alignment { + case left + case right + } + + public struct Item: Equatable { + public var id: AnyHashable + public var text: String + public var numericValue: Int + + public init(id: AnyHashable, text: String, numericValue: Int) { + self.id = id + self.text = text + self.numericValue = numericValue + } + } + + public let font: UIFont + public let color: UIColor + public let alignment: Alignment + public let items: [Item] + + public init( + font: UIFont, + color: UIColor, + alignment: Alignment, + items: [Item] + ) { + self.font = font + self.color = color + self.alignment = alignment + self.items = items + } + + public static func ==(lhs: AnimatedCounterComponent, rhs: AnimatedCounterComponent) -> Bool { + if lhs.font != rhs.font { + return false + } + if lhs.color != rhs.color { + return false + } + if lhs.alignment != rhs.alignment { + return false + } + if lhs.items != rhs.items { + return false + } + return true + } + + private final class ItemView { + let view = ComponentView() + } + + public final class View: UIView { + private var itemViews: [AnyHashable: ItemView] = [:] + + private var component: AnimatedCounterComponent? + private weak var state: EmptyComponentState? + + private var measuredSpaceWidth: CGFloat? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func update(component: AnimatedCounterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let spaceWidth: CGFloat + if let measuredSpaceWidth = self.measuredSpaceWidth, let previousComponent = self.component, previousComponent.font.pointSize == component.font.pointSize { + spaceWidth = measuredSpaceWidth + } else { + spaceWidth = ceil(NSAttributedString(string: " ", font: component.font, textColor: .black).boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil).width) + self.measuredSpaceWidth = spaceWidth + } + + self.component = component + self.state = state + + var size = CGSize() + + var validIds: [AnyHashable] = [] + for item in component.items { + if size.width != 0.0 { + size.width += spaceWidth + } + + validIds.append(item.id) + + let itemView: ItemView + var itemTransition = transition + if let current = self.itemViews[item.id] { + itemView = current + } else { + itemTransition = .immediate + itemView = ItemView() + self.itemViews[item.id] = itemView + } + + let itemSize = itemView.view.update( + transition: itemTransition, + component: AnyComponent(AnimatedCounterItemComponent( + font: component.font, + color: component.color, + text: item.text, + numericValue: item.numericValue, + alignment: component.alignment == .left ? 0.0 : 1.0 + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + + if let itemComponentView = itemView.view.view { + if itemComponentView.superview == nil { + self.addSubview(itemComponentView) + } + let itemFrame = CGRect(origin: CGPoint(x: size.width, y: 0.0), size: itemSize) + switch component.alignment { + case .left: + itemComponentView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) + itemTransition.setPosition(view: itemComponentView, position: CGPoint(x: itemFrame.minX, y: itemFrame.midY)) + case .right: + itemComponentView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5) + itemTransition.setPosition(view: itemComponentView, position: CGPoint(x: itemFrame.maxX, y: itemFrame.midY)) + } + itemComponentView.bounds = CGRect(origin: CGPoint(), size: itemFrame.size) + } + + size.width += itemSize.width + size.height = max(size.height, itemSize.height) + } + + var removeIds: [AnyHashable] = [] + for (id, itemView) in self.itemViews { + if !validIds.contains(id) { + removeIds.append(id) + if let componentView = itemView.view.view { + transition.setAlpha(view: componentView, alpha: 0.0, completion: { [weak componentView] _ in + componentView?.removeFromSuperview() + }) + } + } + } + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD index 585ce7411f..0adf863528 100644 --- a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD +++ b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD @@ -26,6 +26,8 @@ swift_library( "//submodules/PresentationDataUtils", "//submodules/Components/SolidRoundedButtonComponent", "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/AnimatedCounterComponent", "//submodules/AvatarNode", "//submodules/CheckNode", "//submodules/Markdown", diff --git a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift index 10536d30d5..a3bbd80f3b 100644 --- a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift +++ b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift @@ -20,6 +20,8 @@ import ButtonComponent import ContextUI import QrCodeUI import InviteLinksUI +import PlainButtonComponent +import AnimatedCounterComponent private final class ChatFolderLinkPreviewScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -154,7 +156,7 @@ private final class ChatFolderLinkPreviewScreenComponent: Component { self.addSubview(self.navigationBarContainer) - self.scrollView.delaysContentTouches = true + self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { @@ -800,15 +802,21 @@ private final class ChatFolderLinkPreviewScreenComponent: Component { listHeaderTitle = " " } - let listHeaderActionTitle: String + //TODO:localize + let listHeaderActionItems: [AnimatedCounterComponent.Item] if self.selectedItems.count == self.items.count { - listHeaderActionTitle = "DESELECT ALL" + listHeaderActionItems = [ + AnimatedCounterComponent.Item(id: AnyHashable(0), text: "DESELECT", numericValue: 0), + AnimatedCounterComponent.Item(id: AnyHashable(1), text: "ALL", numericValue: 1) + ] } else { - listHeaderActionTitle = "SELECT ALL" + listHeaderActionItems = [ + AnimatedCounterComponent.Item(id: AnyHashable(0), text: "SELECT", numericValue: 1), + AnimatedCounterComponent.Item(id: AnyHashable(1), text: "ALL", numericValue: 1) + ] } let listHeaderBody = MarkdownAttributeSet(font: Font.with(size: 13.0, design: .regular, traits: [.monospacedNumbers]), textColor: environment.theme.list.freeTextColor) - let listHeaderActionBody = MarkdownAttributeSet(font: Font.with(size: 13.0, design: .regular, traits: [.monospacedNumbers]), textColor: environment.theme.list.itemAccentColor) let listHeaderTextSize = self.listHeaderText.update( transition: .immediate, @@ -838,19 +846,15 @@ private final class ChatFolderLinkPreviewScreenComponent: Component { } let listHeaderActionSize = self.listHeaderAction.update( - transition: .immediate, - component: AnyComponent(Button( - content: AnyComponent(MultilineTextComponent( - text: .markdown( - text: listHeaderActionTitle, - attributes: MarkdownAttributes( - body: listHeaderActionBody, - bold: listHeaderActionBody, - link: listHeaderActionBody, - linkAttribute: { _ in nil } - ) - ) + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(AnimatedCounterComponent( + font: Font.regular(13.0), + color: environment.theme.list.itemAccentColor, + alignment: .right, + items: listHeaderActionItems )), + effectAlignment: .right, action: { [weak self] in guard let self, let component = self.component, let linkContents = component.linkContents else { return @@ -877,8 +881,7 @@ private final class ChatFolderLinkPreviewScreenComponent: Component { self.scrollContentView.addSubview(listHeaderActionView) } let listHeaderActionFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - 15.0 - listHeaderActionSize.width, y: contentHeight), size: listHeaderActionSize) - contentTransition.setPosition(view: listHeaderActionView, position: CGPoint(x: listHeaderActionFrame.maxX, y: listHeaderActionFrame.minY)) - listHeaderActionView.bounds = CGRect(origin: CGPoint(), size: listHeaderActionFrame.size) + contentTransition.setFrame(view: listHeaderActionView, frame: listHeaderActionFrame) if let linkContents = component.linkContents, !allChatsAdded, linkContents.peers.count > 1 { listHeaderActionView.isHidden = false diff --git a/submodules/TelegramUI/Components/PlainButtonComponent/BUILD b/submodules/TelegramUI/Components/PlainButtonComponent/BUILD new file mode 100644 index 0000000000..146e69465a --- /dev/null +++ b/submodules/TelegramUI/Components/PlainButtonComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "PlainButtonComponent", + module_name = "PlainButtonComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift b/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift new file mode 100644 index 0000000000..13cfe06639 --- /dev/null +++ b/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift @@ -0,0 +1,154 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class PlainButtonComponent: Component { + public enum EffectAlignment { + case left + case right + } + + public let content: AnyComponent + public let effectAlignment: EffectAlignment + public let action: () -> Void + + public init( + content: AnyComponent, + effectAlignment: EffectAlignment, + action: @escaping () -> Void + ) { + self.content = content + self.effectAlignment = effectAlignment + self.action = action + } + + public static func ==(lhs: PlainButtonComponent, rhs: PlainButtonComponent) -> Bool { + if lhs.content != rhs.content { + return false + } + if lhs.effectAlignment != rhs.effectAlignment { + return false + } + return true + } + + public final class View: HighlightTrackingButton { + private var component: PlainButtonComponent? + private weak var componentState: EmptyComponentState? + + private let contentContainer = UIView() + private let content = ComponentView() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.contentContainer.isUserInteractionEnabled = false + self.addSubview(self.contentContainer) + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + + self.highligthedChanged = { [weak self] highlighted in + if let self, self.bounds.width > 0.0 { + let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width + let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width + + if highlighted { + self.contentContainer.layer.removeAnimation(forKey: "opacity") + self.contentContainer.layer.removeAnimation(forKey: "sublayerTransform") + self.contentContainer.alpha = 0.7 + let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) + transition.setScale(layer: self.contentContainer.layer, scale: topScale) + } else { + self.contentContainer.alpha = 1.0 + self.contentContainer.layer.animateAlpha(from: 7, to: 1.0, duration: 0.2) + + let transition = Transition(animation: .none) + transition.setScale(layer: self.contentContainer.layer, scale: 1.0) + + self.contentContainer.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + guard let self else { + return + } + + self.contentContainer.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + }) + } + } + } + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action() + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + if result != nil { + return result + } + + if !self.isEnabled { + return nil + } + + if self.bounds.insetBy(dx: -8.0, dy: -8.0).contains(point) { + return self + } + + return nil + } + + func update(component: PlainButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.componentState = state + + self.isEnabled = true + + let contentAlpha: CGFloat = 1.0 + + let contentSize = self.content.update( + transition: transition, + component: component.content, + environment: {}, + containerSize: availableSize + ) + + let size = contentSize + + if let contentView = self.content.view { + var contentTransition = transition + if contentView.superview == nil { + contentTransition = .immediate + contentView.isUserInteractionEnabled = false + self.contentContainer.addSubview(contentView) + } + let contentFrame = CGRect(origin: CGPoint(x: floor((size.width - contentSize.width) * 0.5), y: floor((size.height - contentSize.height) * 0.5)), size: contentSize) + + contentTransition.setFrame(view: contentView, frame: contentFrame) + contentTransition.setAlpha(view: contentView, alpha: contentAlpha) + } + + self.contentContainer.layer.anchorPoint = CGPoint(x: component.effectAlignment == .left ? 0.0 : 1.0, y: 0.5) + transition.setBounds(view: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: size)) + transition.setPosition(view: self.contentContainer, position: CGPoint(x: component.effectAlignment == .left ? 0.0 : size.width, y: size.height * 0.5)) + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +}