diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index d11ba48d..5706c111 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -8,48 +8,74 @@ import UIKit -/// The default embedded block placeholder — a neutral tile with a sweeping highlight. -/// -/// Fills the container entirely: the SDK knows nothing about the layout of the content to come, so -/// the placeholder does not depict it and simply marks the reserved spot as "loading". A host that -/// needs a skeleton of its own layout sets the container's `placeholderView`. final class EmbeddedBlockShimmerView: UIView { - private enum Shimmer { - static let animationKey = "embeddedBlockShimmer" - static let animationDuration: CFTimeInterval = 1.4 + /// Positions are fractions of the block's width. + enum Design { - /// Outside 0…1 on purpose: fully off the leading edge at rest, off the trailing one once swept. - static let restingLocations: [NSNumber] = [-1.0, -0.5, 0.0] - static let sweptLocations: [NSNumber] = [1.0, 1.5, 2.0] - } + static let lightTint = UIColor(red: 0x28 / 255.0, green: 0x2A / 255.0, blue: 0x2F / 255.0, alpha: 1.0) + static let darkTint = UIColor.white + + static let restingAlpha: CGFloat = 0.08 + static let lightHighlightAlpha: CGFloat = 0.04 + static let darkHighlightAlpha: CGFloat = 0.16 + + static let stops: [CGFloat] = [0.0, 0.4, 0.5, 0.6, 1.0] + static let layerWidth: CGFloat = 2.96 + static let startX: CGFloat = -1.88 + static let endX: CGFloat = -0.083 + + static let pauseAtStart: CFTimeInterval = 0.6 + static let sweepDuration: CFTimeInterval = 1.0 + static let pauseAtEnd: CFTimeInterval = 0.6 - private let gradientLayer = CAGradientLayer() + static var cycleDuration: CFTimeInterval { pauseAtStart + sweepDuration + pauseAtEnd } - private var baseColor: UIColor { - if #available(iOS 13.0, *) { - return .systemGray5 + static func locations(forLayerAt x: CGFloat) -> [CGFloat] { + stops.map { x + layerWidth * $0 } } - return UIColor(white: 0.90, alpha: 1.0) - } - /// Lighter than the base in both appearances, which the system grays do not give for free: their - /// order flips in the dark, where `systemGray6` is the closest one to black. - private var highlightColor: UIColor { - if #available(iOS 13.0, *) { - return UIColor { $0.userInterfaceStyle == .dark ? .systemGray4 : .systemGray6 } + static var startLocations: [CGFloat] { locations(forLayerAt: startX) } + + static var endLocations: [CGFloat] { locations(forLayerAt: endX) } + + static func alphas(isDark: Bool) -> [CGFloat] { + let highlight = isDark ? darkHighlightAlpha : lightHighlightAlpha + return [restingAlpha, restingAlpha, highlight, restingAlpha, restingAlpha] } - return UIColor(white: 0.96, alpha: 1.0) + + static func colors(isDark: Bool) -> [CGColor] { + let tint = isDark ? darkTint : lightTint + return alphas(isDark: isDark).map { tint.withAlphaComponent($0).cgColor } + } + } + + /// One per process so every shimmer on screen sweeps in the same phase. + static let beatEpoch: CFTimeInterval = CACurrentMediaTime() + + static let animationKey = "embeddedBlockShimmer" + + let gradientLayer = CAGradientLayer() + + private let notificationCenter: NotificationCenter + + private var isDarkAppearance: Bool { + traitCollection.userInterfaceStyle == .dark } - override init(frame: CGRect) { + init(frame: CGRect = .zero, notificationCenter: NotificationCenter = .default) { + self.notificationCenter = notificationCenter super.init(frame: frame) setUp() } + @available(*, unavailable, message: "The shimmer is not created from storyboards") required init?(coder: NSCoder) { - super.init(coder: coder) - setUp() + return nil + } + + deinit { + notificationCenter.removeObserver(self) } override func layoutSubviews() { @@ -74,48 +100,61 @@ final class EmbeddedBlockShimmerView: UIView { private func setUp() { isUserInteractionEnabled = false + backgroundColor = .clear + gradientLayer.actions = ["bounds": NSNull(), "position": NSNull()] gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) - gradientLayer.locations = Shimmer.restingLocations + gradientLayer.locations = Design.startLocations.map { NSNumber(value: Double($0)) } applyColors() layer.addSublayer(gradientLayer) - NotificationCenter.default.addObserver(self, - selector: #selector(applicationWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil) - } - - deinit { - NotificationCenter.default.removeObserver(self) + notificationCenter.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) } private func applyColors() { - gradientLayer.colors = [ - baseColor.cgColor, - highlightColor.cgColor, - baseColor.cgColor - ] + gradientLayer.colors = Design.colors(isDark: isDarkAppearance) } private func startShimmering() { - guard gradientLayer.animation(forKey: Shimmer.animationKey) == nil else { return } + guard gradientLayer.animation(forKey: Self.animationKey) == nil else { return } - let animation = CABasicAnimation(keyPath: "locations") - animation.fromValue = Shimmer.restingLocations - animation.toValue = Shimmer.sweptLocations - animation.duration = Shimmer.animationDuration - animation.repeatCount = .infinity - gradientLayer.add(animation, forKey: Shimmer.animationKey) + gradientLayer.add(Self.makeSweep(beginningAt: gradientLayer.convertTime(Self.beatEpoch, from: nil)), + forKey: Self.animationKey) } private func stopShimmering() { - gradientLayer.removeAnimation(forKey: Shimmer.animationKey) + gradientLayer.removeAnimation(forKey: Self.animationKey) + } + + static func makeSweep(beginningAt beginTime: CFTimeInterval) -> CAKeyframeAnimation { + let start = Design.startLocations.map { NSNumber(value: Double($0)) } + let end = Design.endLocations.map { NSNumber(value: Double($0)) } + let cycle = Design.cycleDuration + + let animation = CAKeyframeAnimation(keyPath: "locations") + animation.values = [start, start, end, end] + animation.keyTimes = [ + 0.0, + NSNumber(value: Design.pauseAtStart / cycle), + NSNumber(value: (Design.pauseAtStart + Design.sweepDuration) / cycle), + 1.0 + ] + animation.timingFunctions = [ + CAMediaTimingFunction(name: .linear), + CAMediaTimingFunction(name: .easeIn), + CAMediaTimingFunction(name: .linear) + ] + animation.duration = cycle + animation.repeatCount = .infinity + animation.beginTime = beginTime + return animation } - /// The system removes infinite CA animations when the app goes to the background — after - /// coming back the highlight has to be started again. + // The system drops infinite animations while the app is in the background. @objc private func applicationWillEnterForeground() { guard window != nil else { return } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index 50bbf227..c76fcf53 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -492,20 +492,20 @@ final class EmbeddedBlockWebViewProvider { } } - private func applyContentRendered(_ count: Int) { + private func applyContentRendered(_ renderedCount: Int) { guard !didReportShownContent else { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the page reported itself again with nothing asked of it — ignoring", category: .embeddedBlocks) return } - guard count > 0 else { + guard renderedCount > 0 else { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered nothing", category: .embeddedBlocks) settle(.empty) return } - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered \(count) item(s)", category: .embeddedBlocks) + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered \(renderedCount) item(s)", category: .embeddedBlocks) didReportShownContent = true renderedElapsed = processingDuration + presentationStopwatch.elapsed settle(.ready) diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift new file mode 100644 index 00000000..3a709e28 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift @@ -0,0 +1,254 @@ +// +// EmbeddedBlockShimmerViewTests.swift +// MindboxTests +// +// Created by vailence on 07.09.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("Embedded block shimmer", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockShimmerViewTests { + + private typealias Design = EmbeddedBlockShimmerView.Design + + private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + // A view put straight into a per-test window crashes the process on the next run-loop turn. + private let host = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 120)) + + init() { + window.addSubview(host) + } + + private let notificationCenter = NotificationCenter() + + private func makeShimmer() -> EmbeddedBlockShimmerView { + EmbeddedBlockShimmerView(frame: CGRect(x: 0, y: 0, width: 320, height: 120), + notificationCenter: notificationCenter) + } + + private func enterForeground() { + notificationCenter.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } + + private func sweep(of shimmer: EmbeddedBlockShimmerView) -> CAKeyframeAnimation? { + shimmer.gradientLayer.animation(forKey: EmbeddedBlockShimmerView.animationKey) as? CAKeyframeAnimation + } + + private struct RGBA: Equatable { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + init(_ color: CGColor) { + UIColor(cgColor: color).getRed(&red, green: &green, blue: &blue, alpha: &alpha) + } + } + + private func isClose(_ lhs: CGFloat, _ rhs: CGFloat) -> Bool { + abs(lhs - rhs) < 0.001 + } + + // MARK: - Color + + @Test("Light appearance is one near-black tint whose opacity dips in the highlight") + func lightAppearanceIsOneTintWithADippingHighlight() { + let colors = Design.colors(isDark: false).map(RGBA.init) + + #expect(colors.count == 5) + #expect(colors.allSatisfy { isClose($0.red, 0x28 / 255.0) && isClose($0.green, 0x2A / 255.0) && isClose($0.blue, 0x2F / 255.0) }) + #expect(zip(colors.map(\.alpha), [0.08, 0.08, 0.04, 0.08, 0.08]).allSatisfy { isClose($0, $1) }) + } + + @Test("Dark appearance is one white tint whose opacity peaks in the highlight") + func darkAppearanceIsOneTintWithAPeakingHighlight() { + let colors = Design.colors(isDark: true).map(RGBA.init) + + #expect(colors.count == 5) + #expect(colors.allSatisfy { isClose($0.red, 1) && isClose($0.green, 1) && isClose($0.blue, 1) }) + #expect(zip(colors.map(\.alpha), [0.08, 0.08, 0.16, 0.08, 0.08]).allSatisfy { isClose($0, $1) }) + } + + @Test("The view paints nothing under the tint: it is a mask over the host's background") + func viewPaintsNothingUnderTheTint() { + let shimmer = makeShimmer() + + #expect(shimmer.backgroundColor == .clear) + #expect(shimmer.gradientLayer.backgroundColor == nil) + #expect(shimmer.isUserInteractionEnabled == false) + #expect(shimmer.gradientLayer.colors?.count == 5) + } + + @Test("The gradient runs horizontally across the whole view") + func gradientRunsHorizontallyAcrossTheView() { + let shimmer = makeShimmer() + + shimmer.layoutIfNeeded() + + #expect(shimmer.gradientLayer.frame == shimmer.bounds) + #expect(shimmer.gradientLayer.startPoint == CGPoint(x: 0, y: 0.5)) + #expect(shimmer.gradientLayer.endPoint == CGPoint(x: 1, y: 0.5)) + } + + @Test("A resize snaps the gradient to the new bounds without an implicit animation") + func resizeDoesNotAnimateTheGradientFrame() { + let shimmer = makeShimmer() + host.addSubview(shimmer) + shimmer.layoutIfNeeded() + + shimmer.frame = CGRect(x: 0, y: 0, width: 200, height: 80) + shimmer.layoutIfNeeded() + + #expect(shimmer.gradientLayer.frame == shimmer.bounds) + #expect(shimmer.gradientLayer.animationKeys() == [EmbeddedBlockShimmerView.animationKey]) + } + + // MARK: - Geometry + + @Test("Stops follow the gradient layer: moving it shifts every stop by the same fraction") + func stopsFollowTheGradientLayer() { + let atZero = Design.locations(forLayerAt: 0) + let shifted = Design.locations(forLayerAt: -1.5) + + #expect(zip(atZero.map { $0 / Design.layerWidth }, Design.stops).allSatisfy { isClose($0, $1) }) + #expect(zip(atZero, shifted).allSatisfy { isClose($0 - $1, 1.5) }) + } + + @Test("At both rest positions the ramp lies outside the block, which shows a flat tint") + func rampIsHiddenAtBothRestPositions() { + let rampAtStart = Design.startLocations[1...3] + let rampAtEnd = Design.endLocations[1...3] + + #expect(rampAtStart.allSatisfy { $0 < 0 }, "before the sweep the ramp waits off the leading edge") + #expect(rampAtEnd.allSatisfy { $0 > 1 }, "after the sweep the ramp has left past the trailing edge") + #expect(Design.startLocations.first! < 0 && Design.startLocations.last! > 1) + #expect(Design.endLocations.first! < 0 && Design.endLocations.last! > 1) + } + + @Test("A fresh shimmer rests at the start position") + func freshShimmerRestsAtTheStartPosition() { + let shimmer = makeShimmer() + + let locations = shimmer.gradientLayer.locations?.map { CGFloat($0.doubleValue) } ?? [] + + #expect(zip(locations, Design.startLocations).allSatisfy { isClose($0, $1) }) + #expect(locations.count == Design.startLocations.count) + } + + // MARK: - Cycle + + @Test("One cycle: rest 0.6 s, sweep 1 s with ease-in, rest 0.6 s, jump back") + func cycleMatchesTheDesign() throws { + let animation = EmbeddedBlockShimmerView.makeSweep(beginningAt: 1) + + #expect(animation.keyPath == "locations") + #expect(isClose(CGFloat(animation.duration), 2.2)) + #expect(animation.repeatCount == .infinity) + + let keyTimes = try #require(animation.keyTimes).map { CGFloat($0.doubleValue) } + #expect(keyTimes.count == 4) + #expect(isClose(keyTimes[0], 0)) + #expect(isClose(keyTimes[1], 0.6 / 2.2)) + #expect(isClose(keyTimes[2], 1.6 / 2.2)) + #expect(isClose(keyTimes[3], 1)) + + let values = try #require(animation.values as? [[NSNumber]]).map { $0.map { CGFloat($0.doubleValue) } } + #expect(values.count == 4) + #expect(values[0] == values[1], "the first rest holds the start position") + #expect(values[2] == values[3], "the second rest holds the end position") + #expect(zip(values[0], Design.startLocations).allSatisfy { isClose($0, $1) }) + #expect(zip(values[2], Design.endLocations).allSatisfy { isClose($0, $1) }) + + let timing = try #require(animation.timingFunctions) + #expect(timing.count == 3) + var sweepControlPoint: [Float] = [0, 0] + timing[1].getControlPoint(at: 1, values: &sweepControlPoint) + #expect(abs(sweepControlPoint[0] - 0.42) < 0.001 && abs(sweepControlPoint[1]) < 0.001, "the sweep itself eases in") + } + + @Test("Starts sweeping in a window and stops when it leaves") + func sweepsOnlyInAWindow() { + let shimmer = makeShimmer() + + #expect(sweep(of: shimmer) == nil) + + host.addSubview(shimmer) + #expect(sweep(of: shimmer) != nil) + + shimmer.removeFromSuperview() + #expect(sweep(of: shimmer) == nil) + } + + @Test("A start while already sweeping never stacks a second sweep") + func repeatedStartKeepsOneSweep() { + let shimmer = makeShimmer() + host.addSubview(shimmer) + + enterForeground() + enterForeground() + + #expect(shimmer.gradientLayer.animationKeys() == [EmbeddedBlockShimmerView.animationKey]) + } + + @Test("Returning to the foreground restarts a sweep the system has dropped") + func foregroundRestartsADroppedSweep() { + let shimmer = makeShimmer() + host.addSubview(shimmer) + shimmer.gradientLayer.removeAllAnimations() + #expect(sweep(of: shimmer) == nil) + + enterForeground() + + #expect(sweep(of: shimmer) != nil) + } + + @Test("Returning to the foreground does not start a shimmer that is off screen") + func foregroundLeavesAnOffScreenShimmerAlone() { + let shimmer = makeShimmer() + + enterForeground() + + #expect(sweep(of: shimmer) == nil) + } + + // MARK: - Shared beat + + @Test("Every shimmer counts its cycle from the same instant, whenever it appears") + func shimmersShareOneBeat() throws { + let first = makeShimmer() + let second = makeShimmer() + host.addSubview(first) + let firstSweep = try #require(sweep(of: first)) + + host.addSubview(second) + let secondSweep = try #require(sweep(of: second)) + + #expect(firstSweep.beginTime == secondSweep.beginTime) + #expect(firstSweep.beginTime == first.gradientLayer.convertTime(EmbeddedBlockShimmerView.beatEpoch, from: nil)) + } + + @Test("The shared beat began in the past, so a new sweep joins the cycle in progress") + func sharedBeatBeganInThePast() { + #expect(EmbeddedBlockShimmerView.beatEpoch > 0) + #expect(EmbeddedBlockShimmerView.beatEpoch <= CACurrentMediaTime()) + } + + @Test("Re-entering a window keeps the beat rather than starting a cycle of its own") + func reEnteringAWindowKeepsTheBeat() throws { + let shimmer = makeShimmer() + host.addSubview(shimmer) + let before = try #require(sweep(of: shimmer)).beginTime + + shimmer.removeFromSuperview() + host.addSubview(shimmer) + let after = try #require(sweep(of: shimmer)).beginTime + + #expect(before == after) + } +}