From 6b589a6e2fe7fb4b94d5efe0c72e6c103235b43c Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 2 Dec 2021 00:44:21 +0400 Subject: [PATCH 1/7] Fix localization list top overscroll background --- .../Language Selection/LocalizationListControllerNode.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift b/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift index decd4d028a..909cb6c91b 100644 --- a/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift +++ b/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift @@ -444,7 +444,7 @@ final class LocalizationListControllerNode: ViewControllerTracingNode { self.presentationData = presentationData self.presentationDataValue.set(.single(presentationData)) self.backgroundColor = presentationData.theme.list.blocksBackgroundColor - self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.chatList.backgroundColor, direction: true) + self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.list.blocksBackgroundColor, direction: true) self.searchDisplayController?.updatePresentationData(presentationData) self.leftOverlayNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor self.rightOverlayNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor From bd2e8430865989875332c2d4bf18a72bbf6dd728 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 2 Dec 2021 04:19:11 +0400 Subject: [PATCH 2/7] Initial spoiler implementation --- submodules/Display/Source/TextNode.swift | 89 +++++++++++-- submodules/InvisibleInkDustNode/BUILD | 21 +++ .../Sources/InvisibleInkDustNode.swift | 121 ++++++++++++++++++ submodules/TelegramUI/BUILD | 1 + .../TextSpeckle.imageset/Contents.json | 21 +++ .../textSpeckle_Normal.png | Bin 0 -> 3507 bytes .../ChatMessageTextBubbleContentNode.swift | 56 +++++++- .../Sources/StringWithAppliedEntities.swift | 3 +- .../Sources/TelegramAttributes.swift | 1 + 9 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 submodules/InvisibleInkDustNode/BUILD create mode 100644 submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift create mode 100644 submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/textSpeckle_Normal.png diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index f1071b2adc..61f2bfdbe1 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -15,6 +15,16 @@ private final class TextNodeStrikethrough { } } +private final class TextNodeSpoiler { + let range: NSRange + let frame: CGRect + + init(range: NSRange, frame: CGRect) { + self.range = range + self.frame = frame + } +} + public struct TextRangeRectEdge: Equatable { public var x: CGFloat public var y: CGFloat @@ -33,13 +43,15 @@ private final class TextNodeLine { let range: NSRange let isRTL: Bool let strikethroughs: [TextNodeStrikethrough] + let spoilers: [TextNodeSpoiler] - init(line: CTLine, frame: CGRect, range: NSRange, isRTL: Bool, strikethroughs: [TextNodeStrikethrough]) { + init(line: CTLine, frame: CGRect, range: NSRange, isRTL: Bool, strikethroughs: [TextNodeStrikethrough], spoilers: [TextNodeSpoiler]) { self.line = line self.frame = frame self.range = range self.isRTL = isRTL self.strikethroughs = strikethroughs + self.spoilers = spoilers } } @@ -117,8 +129,9 @@ public final class TextNodeLayoutArguments { public let lineColor: UIColor? public let textShadowColor: UIColor? public let textStroke: (UIColor, CGFloat)? + public let displaySpoilers: Bool - public init(attributedString: NSAttributedString?, backgroundColor: UIColor? = nil, minimumNumberOfLines: Int = 0, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment = .natural, verticalAlignment: TextVerticalAlignment = .top, lineSpacing: CGFloat = 0.12, cutout: TextNodeCutout? = nil, insets: UIEdgeInsets = UIEdgeInsets(), lineColor: UIColor? = nil, textShadowColor: UIColor? = nil, textStroke: (UIColor, CGFloat)? = nil) { + public init(attributedString: NSAttributedString?, backgroundColor: UIColor? = nil, minimumNumberOfLines: Int = 0, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment = .natural, verticalAlignment: TextVerticalAlignment = .top, lineSpacing: CGFloat = 0.12, cutout: TextNodeCutout? = nil, insets: UIEdgeInsets = UIEdgeInsets(), lineColor: UIColor? = nil, textShadowColor: UIColor? = nil, textStroke: (UIColor, CGFloat)? = nil, displaySpoilers: Bool = false) { self.attributedString = attributedString self.backgroundColor = backgroundColor self.minimumNumberOfLines = minimumNumberOfLines @@ -133,6 +146,7 @@ public final class TextNodeLayoutArguments { self.lineColor = lineColor self.textShadowColor = textShadowColor self.textStroke = textStroke + self.displaySpoilers = displaySpoilers } } @@ -157,9 +171,11 @@ public final class TextNodeLayout: NSObject { fileprivate let lineColor: UIColor? fileprivate let textShadowColor: UIColor? fileprivate let textStroke: (UIColor, CGFloat)? + fileprivate let displaySpoilers: Bool public let hasRTL: Bool + public let spoilers: [CGRect] - fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, explicitAlignment: NSTextAlignment, resolvedAlignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, rawTextSize: CGSize, truncated: Bool, firstLineOffset: CGFloat, lines: [TextNodeLine], blockQuotes: [TextNodeBlockQuote], backgroundColor: UIColor?, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?) { + fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, explicitAlignment: NSTextAlignment, resolvedAlignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, rawTextSize: CGSize, truncated: Bool, firstLineOffset: CGFloat, lines: [TextNodeLine], blockQuotes: [TextNodeBlockQuote], backgroundColor: UIColor?, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) { self.attributedString = attributedString self.maximumNumberOfLines = maximumNumberOfLines self.truncationType = truncationType @@ -180,13 +196,17 @@ public final class TextNodeLayout: NSObject { self.lineColor = lineColor self.textShadowColor = textShadowColor self.textStroke = textStroke + self.displaySpoilers = displaySpoilers var hasRTL = false + var spoilers: [CGRect] = [] for line in lines { if line.isRTL { hasRTL = true } + spoilers.append(contentsOf: line.spoilers.map { $0.frame.offsetBy(dx: line.frame.minX, dy: line.frame.minY) }) } self.hasRTL = hasRTL + self.spoilers = spoilers } public func areLinesEqual(to other: TextNodeLayout) -> Bool { @@ -851,7 +871,7 @@ public class TextNode: ASDisplayNode { } } - private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?) -> TextNodeLayout { + private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout { if let attributedString = attributedString { let stringLength = attributedString.length @@ -890,7 +910,7 @@ public class TextNode: ASDisplayNode { var maybeTypesetter: CTTypesetter? maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) if maybeTypesetter == nil { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) } let typesetter = maybeTypesetter! @@ -931,6 +951,7 @@ public class TextNode: ASDisplayNode { var first = true while true { var strikethroughs: [TextNodeStrikethrough] = [] + var spoilers: [TextNodeSpoiler] = [] var lineConstrainedWidth = constrainedSize.width var lineConstrainedWidthDelta: CGFloat = 0.0 @@ -996,7 +1017,16 @@ public class TextNode: ASDisplayNode { var headIndent: CGFloat = 0.0 attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in - if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + if let _ = attributes[NSAttributedString.Key.init(rawValue: "TelegramSpoiler")] { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: x, y: -descent, width: abs(upperX - lowerX), height: ascent + descent))) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX @@ -1025,7 +1055,7 @@ public class TextNode: ASDisplayNode { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs)) + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers)) break } else { if lineCharacterCount > 0 { @@ -1048,7 +1078,16 @@ public class TextNode: ASDisplayNode { var headIndent: CGFloat = 0.0 attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in - if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + if let _ = attributes[NSAttributedString.Key.init(rawValue: "TelegramSpoiler")] { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: x, y: descent - (ascent + descent), width: abs(upperX - lowerX), height: ascent + descent))) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX @@ -1076,7 +1115,7 @@ public class TextNode: ASDisplayNode { } } - lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs)) + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers)) } else { if !lines.isEmpty { layoutSize.height += fontLineSpacing @@ -1109,9 +1148,9 @@ public class TextNode: ASDisplayNode { } } - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) } else { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) } } @@ -1137,6 +1176,7 @@ public class TextNode: ASDisplayNode { context.setAllowsFontSubpixelQuantization(true) context.setShouldSubpixelQuantizeFonts(true) + var clearRects: [CGRect] = [] if let layout = parameters as? TextNodeLayout { if !isRasterizing || layout.backgroundColor != nil { context.setBlendMode(.copy) @@ -1189,6 +1229,15 @@ public class TextNode: ASDisplayNode { } context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.minY) + if layout.displaySpoilers && !line.spoilers.isEmpty { + context.saveGState() + var clipRects: [CGRect] = [] + for spoiler in line.spoilers { + clipRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) + } + context.clip(to: clipRects) + } + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray if glyphRuns.count != 0 { for run in glyphRuns { @@ -1213,6 +1262,16 @@ public class TextNode: ASDisplayNode { context.fill(CGRect(x: frame.minX, y: frame.minY - 5.0, width: frame.width, height: 1.0)) } } + + if !line.spoilers.isEmpty { + if layout.displaySpoilers { + context.restoreGState() + } else { + for spoiler in line.spoilers { + clearRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)) + } + } + } } var blockQuoteFrames: [CGRect] = [] @@ -1248,6 +1307,10 @@ public class TextNode: ASDisplayNode { } context.setBlendMode(.normal) + + for rect in clearRects { + context.clear(rect) + } } public static func asyncLayout(_ maybeNode: TextNode?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode) { @@ -1282,11 +1345,11 @@ public class TextNode: ASDisplayNode { if stringMatch { layout = existingLayout } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) updated = true } } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) updated = true } diff --git a/submodules/InvisibleInkDustNode/BUILD b/submodules/InvisibleInkDustNode/BUILD new file mode 100644 index 0000000000..684ce8c618 --- /dev/null +++ b/submodules/InvisibleInkDustNode/BUILD @@ -0,0 +1,21 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "InvisibleInkDustNode", + module_name = "InvisibleInkDustNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AppBundle:AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift new file mode 100644 index 0000000000..fa6cef641f --- /dev/null +++ b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift @@ -0,0 +1,121 @@ +import Foundation +import UIKit +import UIKit.UIGestureRecognizerSubclass +import SwiftSignalKit +import AsyncDisplayKit +import Display +import AppBundle + +private func createEmitterBehavior(type: String) -> NSObject { + let selector = ["behaviorWith", "Type:"].joined(separator: "") + let behaviorClass = NSClassFromString(["CA", "Emitter", "Behavior"].joined(separator: "")) as! NSObject.Type + let behaviorWithType = behaviorClass.method(for: NSSelectorFromString(selector))! + let castedBehaviorWithType = unsafeBitCast(behaviorWithType, to:(@convention(c)(Any?, Selector, Any?) -> NSObject).self) + return castedBehaviorWithType(behaviorClass, NSSelectorFromString(selector), type) +} + +public class InvisibleInkDustNode: ASDisplayNode { + private var currentParams: (size: CGSize, color: UIColor, rects: [CGRect])? + + private var emitter: CAEmitterCell? + private var emitterLayer: CAEmitterLayer? + + public override init() { + super.init() + } + + public var isRevealedUpdated: (Bool) -> Void = { _ in } + + public override func didLoad() { + super.didLoad() + + let emitter = CAEmitterCell() + emitter.contents = UIImage(bundleImageName: "Components/TextSpeckle")?.cgImage + emitter.birthRate = 1600.0 + emitter.setValue(1.8, forKey: "contentsScale") + emitter.emissionRange = .pi * 2.0 + emitter.setValue(3.0, forKey: "mass") + emitter.setValue(2.0, forKey: "massRange") + emitter.lifetime = 1.0 + emitter.scale = 0.5 + emitter.velocityRange = 20.0 + emitter.name = "dustCell" + emitter.setValue("point", forKey: "particleType") + emitter.color = UIColor.white.cgColor //?alpha + emitter.alphaRange = 1.0 + self.emitter = emitter + + let fingerAttractor = createEmitterBehavior(type: "simpleAttractor") + fingerAttractor.setValue("fingerAttractor", forKey: "name") + + let alphaBehavior = createEmitterBehavior(type: "valueOverLife") + alphaBehavior.setValue("alphaBehavior", forKey: "name") + alphaBehavior.setValue("color.alpha", forKey: "keyPath") + alphaBehavior.setValue([1.0, 0.0], forKey: "values") +// alphaBehavior.setValue(true, forKey: "additive") + + let behaviors = [fingerAttractor, alphaBehavior] + + let emitterLayer = CAEmitterLayer() + emitterLayer.masksToBounds = true + emitterLayer.allowsGroupOpacity = true + emitterLayer.lifetime = 1 + emitterLayer.emitterCells = [emitter] + emitterLayer.setValue(behaviors, forKey: "emitterBehaviors") + emitterLayer.emitterPosition = CGPoint(x: 0, y: 0) + emitterLayer.seed = arc4random() + emitterLayer.setValue("rectangles", forKey: "emitterShape") + emitterLayer.emitterSize = CGSize(width: 1, height: 1) + emitterLayer.setValue(0.0322, forKey: "updateInterval") + +// layer.setValue(-100, forKeyPath: "emitterBehaviors.fingerAttractor.stiffness") + emitterLayer.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + + self.emitterLayer = emitterLayer + + self.layer.addSublayer(emitterLayer) + + self.updateEmitter() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap))) + } + + @objc private func tap() { + self.isRevealedUpdated(true) + + Queue.mainQueue().after(4.0) { + self.isRevealedUpdated(false) + } + } + + private func updateEmitter() { + guard let (size, color, rects) = self.currentParams else { + return + } + + self.emitter?.color = color.cgColor + self.emitterLayer?.setValue(rects, forKey: "emitterRects") + self.emitterLayer?.frame = CGRect(origin: CGPoint(), size: size) + } + + public func update(size: CGSize, color: UIColor, rects: [CGRect]) { + self.currentParams = (size, color, rects) + + if self.isNodeLoaded { + self.updateEmitter() + } + } + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if let (_, _, rects) = self.currentParams { + for rect in rects { + if rect.contains(point) { + return true + } + } + return false + } else { + return false + } + } +} diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index e56a7b583f..ec5812a2c6 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -248,6 +248,7 @@ swift_library( "//submodules/DirectMediaImageCache:DirectMediaImageCache", "//submodules/CodeInputView:CodeInputView", "//submodules/Components/ReactionButtonListComponent:ReactionButtonListComponent", + "//submodules/InvisibleInkDustNode:InvisibleInkDustNode", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json new file mode 100644 index 0000000000..c87dc0fd29 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "textSpeckle_Normal.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/textSpeckle_Normal.png b/submodules/TelegramUI/Images.xcassets/Components/TextSpeckle.imageset/textSpeckle_Normal.png new file mode 100644 index 0000000000000000000000000000000000000000..f9ee87547ed6a3eedf7bb9bd59898dde22db01a2 GIT binary patch literal 3507 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 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index f08fc28726..db6c3b59bf 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -8,6 +8,7 @@ import TextFormat import UrlEscaping import TelegramUniversalVideoContent import TextSelectionNode +import InvisibleInkDustNode private final class CachedChatMessageText { let text: String @@ -37,6 +38,9 @@ private final class CachedChatMessageText { class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let textNode: TextNode + private var spoilerTextNode: TextNode? + private var dustNode: InvisibleInkDustNode? + private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode private let statusNode: ChatMessageDateAndStatusNode private var linkHighlightingNode: LinkHighlightingNode? @@ -80,6 +84,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { override func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))) { let textLayout = TextNode.asyncLayout(self.textNode) + let spoilerTextLayout = TextNode.asyncLayout(self.spoilerTextNode) let statusLayout = self.statusNode.asyncLayout() let currentCachedChatMessageText = self.cachedChatMessageText @@ -273,6 +278,13 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor)) + let spoilerTextLayoutAndApply: (TextNodeLayout, () -> TextNode)? + if !textLayout.spoilers.isEmpty { + spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true)) + } else { + spoilerTextLayoutAndApply = nil + } + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? if let statusType = statusType { var isReplyThread = false @@ -353,8 +365,50 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.textNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync let _ = textApply() - strongSelf.textNode.frame = textFrame + + if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { + let spoilerTextNode = spoilerTextApply() + if strongSelf.spoilerTextNode == nil { + spoilerTextNode.isUserInteractionEnabled = false + spoilerTextNode.contentMode = .topLeft + spoilerTextNode.contentsScale = UIScreenScale + spoilerTextNode.displaysAsynchronously = false + strongSelf.insertSubnode(spoilerTextNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode) + + strongSelf.spoilerTextNode = spoilerTextNode + } + + strongSelf.spoilerTextNode?.frame = textFrame + strongSelf.spoilerTextNode?.isHidden = false + strongSelf.spoilerTextNode?.alpha = 0.0 + + let dustNode: InvisibleInkDustNode + if let current = strongSelf.dustNode { + dustNode = current + } else { + dustNode = InvisibleInkDustNode() + dustNode.isRevealedUpdated = { [weak self] revealed in + if let strongSelf = self { + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear) + if let dustNode = strongSelf.dustNode { + transition.updateAlpha(node: dustNode, alpha: revealed ? 0.0 : 1.0) + } + if let spoilerTextNode = strongSelf.spoilerTextNode { + transition.updateAlpha(node: spoilerTextNode, alpha: revealed ? 1.0 : 0.0) + } + } + } + strongSelf.dustNode = dustNode + strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode) + } + dustNode.update(size: textFrame.size, color: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: -2.0, dy: 0.0) }) + dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) + } else if let spoilerTextNode = strongSelf.spoilerTextNode { + strongSelf.spoilerTextNode = nil + spoilerTextNode.removeFromSupernode() + } + if let textSelectionNode = strongSelf.textSelectionNode { let shouldUpdateLayout = textSelectionNode.frame.size != textFrame.size textSelectionNode.frame = textFrame diff --git a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift index cfe7903628..622f252f7d 100644 --- a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift +++ b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift @@ -137,7 +137,8 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti } string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention), value: nsString!.substring(with: range), range: range) case .Strikethrough: - string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) + string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler), value: true as NSNumber, range: range) +// string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) case .Underline: string.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) case let .TextMention(peerId): diff --git a/submodules/TextFormat/Sources/TelegramAttributes.swift b/submodules/TextFormat/Sources/TelegramAttributes.swift index 7ce2749909..fb93ec60a2 100644 --- a/submodules/TextFormat/Sources/TelegramAttributes.swift +++ b/submodules/TextFormat/Sources/TelegramAttributes.swift @@ -41,4 +41,5 @@ public struct TelegramTextAttributes { public static let Timecode = "TelegramTimecode" public static let BlockQuote = "TelegramBlockQuote" public static let Pre = "TelegramPre" + public static let Spoiler = "TelegramSpoiler" } From e888f8e5beee2bd126c987ef2470218448362ff0 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 2 Dec 2021 05:14:05 +0400 Subject: [PATCH 3/7] Split spoiler effect by words --- submodules/Display/Source/TextNode.swift | 24 +++++++++++++++---- .../ChatMessageTextBubbleContentNode.swift | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index 61f2bfdbe1..d0ea4632eb 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -1083,10 +1083,26 @@ public class TextNode: ASDisplayNode { var descent: CGFloat = 0.0 CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) - let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) - let x = lowerX < upperX ? lowerX : upperX - spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: x, y: descent - (ascent + descent), width: abs(upperX - lowerX), height: ascent + descent))) + (attributedString.string as NSString).enumerateSubstrings(in: range, options: .byWords) { _, range, _, _ in + let startIndex = range.location + let endIndex = range.location + range.length + + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(coreTextLine, startIndex, &secondaryLeftOffset) + var leftOffset = ceil(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = ceil(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRighOffset = CTLineGetOffsetForStringIndex(coreTextLine, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRighOffset) + if !rawRighOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) + } } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index db6c3b59bf..6da7dcb694 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -402,7 +402,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.dustNode = dustNode strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode) } - dustNode.update(size: textFrame.size, color: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: -2.0, dy: 0.0) }) + dustNode.update(size: textFrame.size, color: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 0.0, dy: 1.0) }) dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) } else if let spoilerTextNode = strongSelf.spoilerTextNode { strongSelf.spoilerTextNode = nil From 0b6227b09cee6c219deecffd021b3f843f36575a Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 2 Dec 2021 05:19:04 +0400 Subject: [PATCH 4/7] Fix --- submodules/Display/Source/TextNode.swift | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index d0ea4632eb..c8d8e8d1a0 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -1022,10 +1022,26 @@ public class TextNode: ASDisplayNode { var descent: CGFloat = 0.0 CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) - let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) - let x = lowerX < upperX ? lowerX : upperX - spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: x, y: -descent, width: abs(upperX - lowerX), height: ascent + descent))) + (attributedString.string as NSString).enumerateSubstrings(in: range, options: .byWords) { _, range, _, _ in + let startIndex = range.location + let endIndex = range.location + range.length + + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(coreTextLine, startIndex, &secondaryLeftOffset) + var leftOffset = ceil(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = ceil(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRighOffset = CTLineGetOffsetForStringIndex(coreTextLine, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRighOffset) + if !rawRighOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilers.append(TextNodeSpoiler(range: range, frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) + } } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) From b3eb582c22eac0576cc016caa3bc7d43bf5b366a Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 2 Dec 2021 17:15:54 +0400 Subject: [PATCH 5/7] Tune dust --- .../Sources/InvisibleInkDustNode.swift | 46 +++++++++++++------ .../ChatMessageTextBubbleContentNode.swift | 2 +- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift index fa6cef641f..b42347bd9c 100644 --- a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift +++ b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift @@ -19,11 +19,7 @@ public class InvisibleInkDustNode: ASDisplayNode { private var emitter: CAEmitterCell? private var emitterLayer: CAEmitterLayer? - - public override init() { - super.init() - } - + public var isRevealedUpdated: (Bool) -> Void = { _ in } public override func didLoad() { @@ -31,7 +27,6 @@ public class InvisibleInkDustNode: ASDisplayNode { let emitter = CAEmitterCell() emitter.contents = UIImage(bundleImageName: "Components/TextSpeckle")?.cgImage - emitter.birthRate = 1600.0 emitter.setValue(1.8, forKey: "contentsScale") emitter.emissionRange = .pi * 2.0 emitter.setValue(3.0, forKey: "mass") @@ -41,7 +36,7 @@ public class InvisibleInkDustNode: ASDisplayNode { emitter.velocityRange = 20.0 emitter.name = "dustCell" emitter.setValue("point", forKey: "particleType") - emitter.color = UIColor.white.cgColor //?alpha + emitter.color = UIColor.white.withAlphaComponent(0.0).cgColor emitter.alphaRange = 1.0 self.emitter = emitter @@ -51,8 +46,8 @@ public class InvisibleInkDustNode: ASDisplayNode { let alphaBehavior = createEmitterBehavior(type: "valueOverLife") alphaBehavior.setValue("alphaBehavior", forKey: "name") alphaBehavior.setValue("color.alpha", forKey: "keyPath") - alphaBehavior.setValue([1.0, 0.0], forKey: "values") -// alphaBehavior.setValue(true, forKey: "additive") + alphaBehavior.setValue([0.0, 0.0, 1.0, 0.0, -1.0], forKey: "values") + alphaBehavior.setValue(true, forKey: "additive") let behaviors = [fingerAttractor, alphaBehavior] @@ -68,7 +63,7 @@ public class InvisibleInkDustNode: ASDisplayNode { emitterLayer.emitterSize = CGSize(width: 1, height: 1) emitterLayer.setValue(0.0322, forKey: "updateInterval") -// layer.setValue(-100, forKeyPath: "emitterBehaviors.fingerAttractor.stiffness") + emitterLayer.setValue(4.0, forKeyPath: "emitterBehaviors.fingerAttractor.stiffness") emitterLayer.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") self.emitterLayer = emitterLayer @@ -77,13 +72,27 @@ public class InvisibleInkDustNode: ASDisplayNode { self.updateEmitter() - self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap))) + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap(_:)))) } - @objc private func tap() { - self.isRevealedUpdated(true) + private var revealed = false + @objc private func tap(_ gestureRecognizer: UITapGestureRecognizer) { + guard !self.revealed else { + return + } + self.revealed = true + + let position = gestureRecognizer.location(in: self.view) + self.emitterLayer?.setValue(true, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + self.emitterLayer?.setValue(position, forKeyPath: "emitterBehaviors.fingerAttractor.position") + + Queue.mainQueue().after(0.2) { + self.isRevealedUpdated(true) + } Queue.mainQueue().after(4.0) { + self.revealed = false + self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") self.isRevealedUpdated(false) } } @@ -96,6 +105,17 @@ public class InvisibleInkDustNode: ASDisplayNode { self.emitter?.color = color.cgColor self.emitterLayer?.setValue(rects, forKey: "emitterRects") self.emitterLayer?.frame = CGRect(origin: CGPoint(), size: size) + + let radius = max(size.width, size.height) + self.emitterLayer?.setValue(max(size.width, size.height), forKeyPath: "emitterBehaviors.fingerAttractor.radius") + self.emitterLayer?.setValue(radius * -0.5, forKeyPath: "emitterBehaviors.fingerAttractor.falloff") + + var square: Float = 0.0 + for rect in rects { + square += Float(rect.width * rect.height) + } + + self.emitter?.birthRate = square * 0.3 } public func update(size: CGSize, color: UIColor, rects: [CGRect]) { diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index 6da7dcb694..5b8f4661d8 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -390,7 +390,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { dustNode = InvisibleInkDustNode() dustNode.isRevealedUpdated = { [weak self] revealed in if let strongSelf = self { - let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear) + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) if let dustNode = strongSelf.dustNode { transition.updateAlpha(node: dustNode, alpha: revealed ? 0.0 : 1.0) } From a217e4c1aaa3ec4585e684dba0477f928c590827 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 2 Dec 2021 18:29:19 +0400 Subject: [PATCH 6/7] Improve spoiler reveal --- .../Sources/InvisibleInkDustNode.swift | 48 +++++++++++++++++- .../TextSpot.imageset/Contents.json | 21 ++++++++ .../TextSpot.imageset/blurSmall_Normal@3x.png | Bin 0 -> 6505 bytes .../ChatMessageTextBubbleContentNode.swift | 13 +---- 4 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/blurSmall_Normal@3x.png diff --git a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift index b42347bd9c..84e8c537d1 100644 --- a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift +++ b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift @@ -17,11 +17,28 @@ private func createEmitterBehavior(type: String) -> NSObject { public class InvisibleInkDustNode: ASDisplayNode { private var currentParams: (size: CGSize, color: UIColor, rects: [CGRect])? + private weak var textNode: TextNode? + + private let maskNode: ASDisplayNode + private let spotNode: ASImageNode + private var emitter: CAEmitterCell? private var emitterLayer: CAEmitterLayer? public var isRevealedUpdated: (Bool) -> Void = { _ in } + public init(textNode: TextNode) { + self.textNode = textNode + + self.maskNode = ASDisplayNode() + self.spotNode = ASImageNode() + self.spotNode.image = UIImage(bundleImageName: "Components/TextSpot") + + super.init() + + self.maskNode.addSubnode(self.spotNode) + } + public override func didLoad() { super.didLoad() @@ -77,23 +94,48 @@ public class InvisibleInkDustNode: ASDisplayNode { private var revealed = false @objc private func tap(_ gestureRecognizer: UITapGestureRecognizer) { - guard !self.revealed else { + guard let (size, _, _) = self.currentParams, !self.revealed else { return } + self.revealed = true let position = gestureRecognizer.location(in: self.view) self.emitterLayer?.setValue(true, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") self.emitterLayer?.setValue(position, forKeyPath: "emitterBehaviors.fingerAttractor.position") + self.textNode?.view.mask = self.maskNode.view + self.textNode?.alpha = 1.0 + + let radius = max(size.width, size.height) + self.spotNode.frame = CGRect(x: position.x - radius / 2.0, y: position.y - radius / 2.0, width: radius, height: radius) + + self.spotNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + self.spotNode.layer.animateScale(from: 0.0, to: 3.5, duration: 0.61, removeOnCompletion: false, completion: { [weak self] _ in + self?.textNode?.view.mask = nil + }) + Queue.mainQueue().after(0.2) { + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) + transition.updateAlpha(node: self, alpha: 0.0) + self.isRevealedUpdated(true) } + Queue.mainQueue().after(0.7) { + self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + self.spotNode.layer.removeAllAnimations() + } + Queue.mainQueue().after(4.0) { self.revealed = false - self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") self.isRevealedUpdated(false) + + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) + transition.updateAlpha(node: self, alpha: 1.0) + if let textNode = self.textNode { + transition.updateAlpha(node: textNode, alpha: 0.0) + } } } @@ -120,6 +162,8 @@ public class InvisibleInkDustNode: ASDisplayNode { public func update(size: CGSize, color: UIColor, rects: [CGRect]) { self.currentParams = (size, color, rects) + + self.maskNode.frame = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: size) if self.isNodeLoaded { self.updateEmitter() diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json new file mode 100644 index 0000000000..dce2efef0a --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "blurSmall_Normal@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/blurSmall_Normal@3x.png b/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/blurSmall_Normal@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f0dcdbc3f6f4d2505f8cf51e948b85f70fe13f GIT binary patch literal 6505 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 P00000NkvXXu0mjfv{_IY literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index 5b8f4661d8..d5bdadc345 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -387,18 +387,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { if let current = strongSelf.dustNode { dustNode = current } else { - dustNode = InvisibleInkDustNode() - dustNode.isRevealedUpdated = { [weak self] revealed in - if let strongSelf = self { - let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) - if let dustNode = strongSelf.dustNode { - transition.updateAlpha(node: dustNode, alpha: revealed ? 0.0 : 1.0) - } - if let spoilerTextNode = strongSelf.spoilerTextNode { - transition.updateAlpha(node: spoilerTextNode, alpha: revealed ? 1.0 : 0.0) - } - } - } + dustNode = InvisibleInkDustNode(textNode: spoilerTextNode) strongSelf.dustNode = dustNode strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode) } From e39736da87299bdc568de6a9c620c668e08324be Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 3 Dec 2021 17:33:58 +0400 Subject: [PATCH 7/7] Improve spoiler reveal --- .../Sources/InvisibleInkDustNode.swift | 120 ++++++++++++++---- .../TextSpot.imageset/Contents.json | 21 --- .../TextSpot.imageset/blurSmall_Normal@3x.png | Bin 6505 -> 0 bytes 3 files changed, 92 insertions(+), 49 deletions(-) delete mode 100644 submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json delete mode 100644 submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/blurSmall_Normal@3x.png diff --git a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift index 84e8c537d1..a90a3df2ad 100644 --- a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift +++ b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift @@ -14,29 +14,78 @@ private func createEmitterBehavior(type: String) -> NSObject { return castedBehaviorWithType(behaviorClass, NSSelectorFromString(selector), type) } +private let textMaskImage: UIImage = { + return generateImage(CGSize(width: 60.0, height: 60.0), rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + var locations: [CGFloat] = [0.0, 0.7, 0.95, 1.0] + let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: size.width / 2.0, options: .drawsAfterEndLocation) + })! +}() + +private let emitterMaskImage: UIImage = { + return generateImage(CGSize(width: 120.0, height: 120.0), rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + var locations: [CGFloat] = [0.0, 0.7, 0.95, 1.0] + let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: size.width / 10.0, options: .drawsAfterEndLocation) + })! +}() + public class InvisibleInkDustNode: ASDisplayNode { private var currentParams: (size: CGSize, color: UIColor, rects: [CGRect])? private weak var textNode: TextNode? + private let textMaskNode: ASDisplayNode + private let textSpotNode: ASImageNode - private let maskNode: ASDisplayNode - private let spotNode: ASImageNode - + private var emitterNode: ASDisplayNode private var emitter: CAEmitterCell? private var emitterLayer: CAEmitterLayer? - + private let emitterMaskNode: ASDisplayNode + private let emitterSpotNode: ASImageNode + private let emitterMaskFillNode: ASDisplayNode + public var isRevealedUpdated: (Bool) -> Void = { _ in } public init(textNode: TextNode) { self.textNode = textNode - self.maskNode = ASDisplayNode() - self.spotNode = ASImageNode() - self.spotNode.image = UIImage(bundleImageName: "Components/TextSpot") + self.emitterNode = ASDisplayNode() + self.emitterNode.clipsToBounds = true + + self.textMaskNode = ASDisplayNode() + self.textSpotNode = ASImageNode() + let img = textMaskImage + self.textSpotNode.image = img + self.emitterMaskNode = ASDisplayNode() + self.emitterSpotNode = ASImageNode() + let simg = emitterMaskImage + self.emitterSpotNode.image = simg + + self.emitterMaskFillNode = ASDisplayNode() + self.emitterMaskFillNode.backgroundColor = .white + super.init() - self.maskNode.addSubnode(self.spotNode) + self.addSubnode(self.emitterNode) + + self.textMaskNode.addSubnode(self.textSpotNode) + self.emitterMaskNode.addSubnode(self.emitterSpotNode) + self.emitterMaskNode.addSubnode(self.emitterMaskFillNode) } public override func didLoad() { @@ -85,7 +134,7 @@ public class InvisibleInkDustNode: ASDisplayNode { self.emitterLayer = emitterLayer - self.layer.addSublayer(emitterLayer) + self.emitterNode.layer.addSublayer(emitterLayer) self.updateEmitter() @@ -103,31 +152,43 @@ public class InvisibleInkDustNode: ASDisplayNode { let position = gestureRecognizer.location(in: self.view) self.emitterLayer?.setValue(true, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") self.emitterLayer?.setValue(position, forKeyPath: "emitterBehaviors.fingerAttractor.position") - - self.textNode?.view.mask = self.maskNode.view - self.textNode?.alpha = 1.0 - - let radius = max(size.width, size.height) - self.spotNode.frame = CGRect(x: position.x - radius / 2.0, y: position.y - radius / 2.0, width: radius, height: radius) - - self.spotNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - self.spotNode.layer.animateScale(from: 0.0, to: 3.5, duration: 0.61, removeOnCompletion: false, completion: { [weak self] _ in - self?.textNode?.view.mask = nil - }) - - Queue.mainQueue().after(0.2) { - let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) - transition.updateAlpha(node: self, alpha: 0.0) + + Queue.mainQueue().after(0.1 * UIView.animationDurationFactor()) { + self.textNode?.view.mask = self.textMaskNode.view + self.textNode?.alpha = 1.0 + + let radius = max(size.width, size.height) + self.textSpotNode.frame = CGRect(x: position.x - radius / 2.0, y: position.y - radius / 2.0, width: radius, height: radius) + + self.textSpotNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + self.textSpotNode.layer.animateScale(from: 0.1, to: 3.5, duration: 0.71, removeOnCompletion: false, completion: { [weak self] _ in + self?.textNode?.view.mask = nil + }) + + self.emitterNode.view.mask = self.emitterMaskNode.view + let emitterSide = radius * 5.0 + self.emitterSpotNode.frame = CGRect(x: position.x - emitterSide / 2.0, y: position.y - emitterSide / 2.0, width: emitterSide, height: emitterSide) + self.emitterSpotNode.layer.animateScale(from: 0.1, to: 3.0, duration: 0.71, removeOnCompletion: false, completion: { [weak self] _ in + self?.alpha = 0.0 + self?.emitterNode.view.mask = nil + }) + self.emitterMaskFillNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + +// let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .linear) +// transition.updateAlpha(node: self, alpha: 0.0) self.isRevealedUpdated(true) } - Queue.mainQueue().after(0.7) { + Queue.mainQueue().after(0.8 * UIView.animationDurationFactor()) { self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") - self.spotNode.layer.removeAllAnimations() + self.textSpotNode.layer.removeAllAnimations() + + self.emitterSpotNode.layer.removeAllAnimations() + self.emitterMaskFillNode.layer.removeAllAnimations() } - Queue.mainQueue().after(4.0) { + Queue.mainQueue().after(4.0 * UIView.animationDurationFactor()) { self.revealed = false self.isRevealedUpdated(false) @@ -163,7 +224,10 @@ public class InvisibleInkDustNode: ASDisplayNode { public func update(size: CGSize, color: UIColor, rects: [CGRect]) { self.currentParams = (size, color, rects) - self.maskNode.frame = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: size) + self.emitterNode.frame = CGRect(origin: CGPoint(), size: size) + self.emitterMaskNode.frame = self.emitterNode.bounds + self.emitterMaskFillNode.frame = self.emitterNode.bounds + self.textMaskNode.frame = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: size) if self.isNodeLoaded { self.updateEmitter() diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json deleted file mode 100644 index dce2efef0a..0000000000 --- a/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "blurSmall_Normal@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/blurSmall_Normal@3x.png b/submodules/TelegramUI/Images.xcassets/Components/TextSpot.imageset/blurSmall_Normal@3x.png deleted file mode 100644 index e9f0dcdbc3f6f4d2505f8cf51e948b85f70fe13f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6505 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 P00000NkvXXu0mjfv{_IY