From 41dd367e0ab6b4ff1b146f5a8bd38359f9b6b2cc Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 7 Sep 2026 16:29:24 +0500 Subject: [PATCH 1/6] MOBILE-438: Silence the empty_count false positive on the rendered count The page reports how many elements it rendered as an Int: SwiftLint 0.59 flags the comparison anyway, and the test build fails on it. --- .../EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index 50bbf227..bd1f3320 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -499,6 +499,8 @@ final class EmbeddedBlockWebViewProvider { return } + // `count` is a number the page reported, not a collection: there is no `isEmpty` to prefer. + // swiftlint:disable:next empty_count guard count > 0 else { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered nothing", category: .embeddedBlocks) settle(.empty) From 67fc02efbe04f4929d5c5028622ea191addeabbb Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 7 Sep 2026 16:29:24 +0500 Subject: [PATCH 2/6] MOBILE-438: Redraw the embedded block shimmer to the design One tint over the host's background whose opacity alone changes: #282A2F in light with a 4% dip, #FFFFFF in dark with a 16% peak, 8% elsewhere. A gradient layer 296% of the block's width sweeps from -188% to -8.3% in one second with ease-in, resting 0.6 s at both ends and jumping back. Every shimmer anchors its cycle to one shared clock, so several blocks on a screen move to the same beat. --- .../Container/EmbeddedBlockShimmerView.swift | 186 ++++++++++++--- .../EmbeddedBlockShimmerViewTests.swift | 222 ++++++++++++++++++ 2 files changed, 371 insertions(+), 37 deletions(-) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index d11ba48d..aedf3b3c 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -8,40 +8,126 @@ import UIKit -/// The default embedded block placeholder — a neutral tile with a sweeping highlight. +/// The default embedded block placeholder — the Mindbox UI Library "Shimmer": a translucent tint +/// over the host's own background with a highlight sweeping across it. /// /// 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`. +/// +/// The design (Figma, Mobile Launchpad → "Шиммер вью для встроенных блоков") in one paragraph: the +/// shimmer is a mask, not a tile. It paints no base color of its own — a single tint (near-black in +/// light appearance, white in dark) at 8% opacity, so the same view reads on a white screen, a +/// brand color and a dark theme alike. The highlight is the only place where that opacity changes: +/// it dips to 4% in light (a lighter spot) and peaks at 16% in dark (a brighter spot). Every size +/// is a fraction of the block's width, never a point value: the same file serves an 80-point +/// avatar and a full-width banner. +/// +/// Several shimmers on one screen move to one beat: each animation is anchored to a clock shared +/// by the whole process, so a block that appears later joins the sweep already in progress instead +/// of starting its own. +/// +/// What is deliberately left out is the design's layer blur. It is about 5.6% of the block's +/// width, which against a gradient layer almost three blocks wide is under 2% of the ramp it would +/// soften — a change of a few hundredths in an opacity that is 8% to begin with. Core Animation +/// offers no public blur for a layer on iOS, and the result would be indistinguishable from the +/// plain linear ramp anyway. final class EmbeddedBlockShimmerView: UIView { - private enum Shimmer { - static let animationKey = "embeddedBlockShimmer" - static let animationDuration: CFTimeInterval = 1.4 + /// The design's numbers. Positions are fractions of the block's width (`W`). + 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] - } + // MARK: Color - private let gradientLayer = CAGradientLayer() + /// The light-appearance tint, `#282A2F`; its opacity does the work. + static let lightTint = UIColor(red: 0x28 / 255.0, green: 0x2A / 255.0, blue: 0x2F / 255.0, alpha: 1.0) - private var baseColor: UIColor { - if #available(iOS 13.0, *) { - return .systemGray5 + /// The dark-appearance tint, `#FFFFFF`. + static let darkTint = UIColor.white + + /// The tint's opacity everywhere but the highlight, both appearances. + static let restingAlpha: CGFloat = 0.08 + + /// The highlight in light appearance dips — a spot lighter than the base. + static let lightHighlightAlpha: CGFloat = 0.04 + + /// The highlight in dark appearance peaks — a spot brighter than the base. + static let darkHighlightAlpha: CGFloat = 0.16 + + // MARK: Geometry + + /// Where the tint changes opacity, as fractions of the gradient layer's width: flat, a + /// ramp into the highlight between 40% and 60%, flat again. + static let stops: [CGFloat] = [0.0, 0.4, 0.5, 0.6, 1.0] + + /// The gradient layer is wider than the block so that both rest positions keep the whole + /// ramp out of sight and the block shows a flat 8% at either end of the cycle. + static let layerWidth: CGFloat = 2.96 + + /// The gradient layer's leading edge at rest before the sweep, in `W`. + static let startX: CGFloat = -1.88 + + /// The gradient layer's leading edge at rest after the sweep, in `W`. + static let endX: CGFloat = -0.083 + + // MARK: Timing + + /// Flat at the start position before the highlight sets off. + static let pauseAtStart: CFTimeInterval = 0.6 + + /// The sweep itself, ease-in: the highlight leaves slowly and exits fast. + static let sweepDuration: CFTimeInterval = 1.0 + + /// Flat at the end position; then the layer jumps back to the start. Both rest positions + /// look identical, so the jump is invisible and the way back is not animated. + static let pauseAtEnd: CFTimeInterval = 0.6 + + static var cycleDuration: CFTimeInterval { pauseAtStart + sweepDuration + pauseAtEnd } + + /// The stops in the block's own coordinates: `0` is its leading edge, `1` its trailing one. + /// Moving the gradient layer by `x` is the same as shifting every stop by `x` — and stops + /// are unit-less, so the animation never has to be rebuilt when the block is laid out. + static func locations(forLayerAt x: CGFloat) -> [CGFloat] { + stops.map { x + layerWidth * $0 } + } + + static var startLocations: [CGFloat] { locations(forLayerAt: startX) } + + static var endLocations: [CGFloat] { locations(forLayerAt: endX) } + + /// The tint's opacity at each stop for the given appearance. + static func alphas(isDark: Bool) -> [CGFloat] { + let highlight = isDark ? darkHighlightAlpha : lightHighlightAlpha + return [restingAlpha, restingAlpha, highlight, restingAlpha, restingAlpha] + } + + static func colors(isDark: Bool) -> [CGColor] { + let tint = isDark ? darkTint : lightTint + return alphas(isDark: isDark).map { tint.withAlphaComponent($0).cgColor } } - 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 { + // MARK: - Shared beat + + /// The instant every shimmer counts its cycle from. One per process: two blocks side by side + /// — or a block that shows up a screen later — are at the same point of the same cycle. + static let beatEpoch: CFTimeInterval = CACurrentMediaTime() + + static let animationKey = "embeddedBlockShimmer" + + // MARK: - State + + let gradientLayer = CAGradientLayer() + + private var isDarkAppearance: Bool { if #available(iOS 13.0, *) { - return UIColor { $0.userInterfaceStyle == .dark ? .systemGray4 : .systemGray6 } + return traitCollection.userInterfaceStyle == .dark } - return UIColor(white: 0.96, alpha: 1.0) + return false } + // MARK: - Life cycle + override init(frame: CGRect) { super.init(frame: frame) setUp() @@ -52,6 +138,10 @@ final class EmbeddedBlockShimmerView: UIView { setUp() } + deinit { + NotificationCenter.default.removeObserver(self) + } + override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds @@ -74,10 +164,12 @@ final class EmbeddedBlockShimmerView: UIView { private func setUp() { isUserInteractionEnabled = false + // A mask over the host's background: nothing of its own underneath the tint. + backgroundColor = .clear 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) @@ -87,35 +179,55 @@ final class EmbeddedBlockShimmerView: UIView { object: nil) } - deinit { - NotificationCenter.default.removeObserver(self) - } - private func applyColors() { - gradientLayer.colors = [ - baseColor.cgColor, - highlightColor.cgColor, - baseColor.cgColor - ] + gradientLayer.colors = Design.colors(isDark: isDarkAppearance) } + // MARK: - Animation + 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) + } + + /// One cycle of the design, repeated forever: rest, sweep, rest, jump back. + /// + /// `beginTime` lies in the past for every shimmer but the very first: Core Animation then picks + /// the cycle up at the phase the shared clock dictates rather than from the start, which is what + /// keeps every shimmer on screen in step. + 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. + /// coming back the sweep has to be started again. Anchored to the shared clock, it comes back + /// at the right phase. @objc private func applicationWillEnterForeground() { guard window != nil else { return } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift new file mode 100644 index 00000000..2e170219 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift @@ -0,0 +1,222 @@ +// +// EmbeddedBlockShimmerViewTests.swift +// MindboxTests +// +// Created by vailence on 07.09.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +/// The stock placeholder against its design: a mask of one tint whose opacity alone changes, sized +/// in fractions of the block, sweeping once per cycle, and in step with every other shimmer around. +@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)) + + private func makeShimmer() -> EmbeddedBlockShimmerView { + EmbeddedBlockShimmerView(frame: CGRect(x: 0, y: 0, width: 320, height: 120)) + } + + 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)) + } + + // 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() { + // The ramp is the three middle stops; the outer two are flat and may be anywhere. + 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") + // And the flat outer stops still cover the block from both sides at either rest. + #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) + + window.addSubview(shimmer) + #expect(sweep(of: shimmer) != nil) + + shimmer.removeFromSuperview() + #expect(sweep(of: shimmer) == nil) + } + + @Test("Returning to the foreground restarts a sweep the system has dropped") + func foregroundRestartsADroppedSweep() { + let shimmer = makeShimmer() + window.addSubview(shimmer) + // The system removes infinite animations while the app is in the background. + shimmer.gradientLayer.removeAllAnimations() + #expect(sweep(of: shimmer) == nil) + + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + #expect(sweep(of: shimmer) != nil) + } + + @Test("Returning to the foreground does not start a shimmer that is off screen") + func foregroundLeavesAnOffScreenShimmerAlone() { + let shimmer = makeShimmer() + + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + #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() + window.addSubview(first) + let firstSweep = try #require(sweep(of: first)) + + // The second one comes later, mid-cycle of the first. + window.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() + window.addSubview(shimmer) + let before = try #require(sweep(of: shimmer)).beginTime + + shimmer.removeFromSuperview() + window.addSubview(shimmer) + let after = try #require(sweep(of: shimmer)).beginTime + + #expect(before == after) + } +} From deaccd2d5591edd955241ab169b6244c52344ff2 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 7 Sep 2026 18:42:02 +0500 Subject: [PATCH 3/6] MOBILE-438: Take the shimmer review's notes The rendered count gets a name the empty_count rule does not mistake for a collection, so the lint directive goes. The shimmer takes its notification center from the outside, and the tests post the foreground notification to a private one instead of the process-wide center. A resize snaps rather than animating the tint into the new bounds. The class comment states the blur's share of the ramp correctly and notes that the shared beat depends on the host leaving the layer clock alone. --- .../Container/EmbeddedBlockShimmerView.swift | 47 +++++++++++-------- .../EmbeddedBlockWebViewProvider.swift | 8 ++-- .../EmbeddedBlockShimmerViewTests.swift | 27 +++++++++-- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index aedf3b3c..2319e0e2 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -25,13 +25,14 @@ import UIKit /// /// Several shimmers on one screen move to one beat: each animation is anchored to a clock shared /// by the whole process, so a block that appears later joins the sweep already in progress instead -/// of starting its own. +/// of starting its own. The beat holds as long as the host leaves the layer clock alone: an ancestor +/// layer with its own `speed` or `timeOffset` shifts the shimmers under it. /// /// What is deliberately left out is the design's layer blur. It is about 5.6% of the block's -/// width, which against a gradient layer almost three blocks wide is under 2% of the ramp it would -/// soften — a change of a few hundredths in an opacity that is 8% to begin with. Core Animation -/// offers no public blur for a layer on iOS, and the result would be indistinguishable from the -/// plain linear ramp anyway. +/// width — under 2% of the gradient layer, about a tenth of the ramp it would soften — and it +/// would move an opacity that is 8% to begin with by a few hundredths. Core Animation offers no +/// public blur for a layer on iOS, and the result would be indistinguishable from the plain +/// linear ramp anyway. final class EmbeddedBlockShimmerView: UIView { /// The design's numbers. Positions are fractions of the block's width (`W`). @@ -113,38 +114,46 @@ final class EmbeddedBlockShimmerView: UIView { /// — or a block that shows up a screen later — are at the same point of the same cycle. static let beatEpoch: CFTimeInterval = CACurrentMediaTime() - static let animationKey = "embeddedBlockShimmer" - // MARK: - State + static let animationKey = "embeddedBlockShimmer" + let gradientLayer = CAGradientLayer() + private let notificationCenter: NotificationCenter + + /// `.unspecified` and `.light` alike take the light tint; on iOS 12 the style is always `.light`. private var isDarkAppearance: Bool { - if #available(iOS 13.0, *) { - return traitCollection.userInterfaceStyle == .dark - } - return false + traitCollection.userInterfaceStyle == .dark } // MARK: - Life cycle - override init(frame: CGRect) { + /// - Parameter notificationCenter: Where the app's foreground notification comes from. Injected + /// so that tests do not have to post to the process-wide center. + 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.default.removeObserver(self) + notificationCenter.removeObserver(self) } + /// A resize snaps: the implicit 0.25 s action a standalone sublayer gets would make the tint + /// grow into new bounds instead of filling them at once. override func layoutSubviews() { super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(true) gradientLayer.frame = bounds + CATransaction.commit() } override func didMoveToWindow() { @@ -173,10 +182,10 @@ final class EmbeddedBlockShimmerView: UIView { applyColors() layer.addSublayer(gradientLayer) - NotificationCenter.default.addObserver(self, - selector: #selector(applicationWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil) + notificationCenter.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) } private func applyColors() { diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index bd1f3320..c76fcf53 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -492,22 +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 } - // `count` is a number the page reported, not a collection: there is no `isEmpty` to prefer. - // swiftlint:disable:next empty_count - 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 index 2e170219..5c4bfbf1 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift @@ -20,8 +20,17 @@ struct EmbeddedBlockShimmerViewTests { private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + /// Private: the foreground notification posted here must not reach the SDK's own observers or + /// the tests running alongside. + private let notificationCenter = NotificationCenter() + private func makeShimmer() -> EmbeddedBlockShimmerView { - EmbeddedBlockShimmerView(frame: CGRect(x: 0, y: 0, width: 320, height: 120)) + 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? { @@ -162,6 +171,18 @@ struct EmbeddedBlockShimmerViewTests { #expect(sweep(of: shimmer) == nil) } + @Test("A start while already sweeping never stacks a second sweep") + func repeatedStartKeepsOneSweep() { + let shimmer = makeShimmer() + window.addSubview(shimmer) + + // The foreground notification is a second start request on a shimmer already on screen. + 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() @@ -170,7 +191,7 @@ struct EmbeddedBlockShimmerViewTests { shimmer.gradientLayer.removeAllAnimations() #expect(sweep(of: shimmer) == nil) - NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + enterForeground() #expect(sweep(of: shimmer) != nil) } @@ -179,7 +200,7 @@ struct EmbeddedBlockShimmerViewTests { func foregroundLeavesAnOffScreenShimmerAlone() { let shimmer = makeShimmer() - NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + enterForeground() #expect(sweep(of: shimmer) == nil) } From ac12baeef68afbc73fad70adbe169f0739f2f574 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 7 Sep 2026 18:51:59 +0500 Subject: [PATCH 4/6] MOBILE-438: Host the shimmer under a view in the test window A view placed straight into a UIWindow that is released at the end of a test left the test process crashing on the next run-loop turn, about every other run. In the SDK the shimmer always sits inside the block container, so the tests now give it a host view inside the window too. --- .../EmbeddedBlockShimmerViewTests.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift index 5c4bfbf1..8c09a7e5 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift @@ -20,6 +20,15 @@ struct EmbeddedBlockShimmerViewTests { private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + /// The shimmer goes here, not straight into the window: in the SDK it always sits inside the block + /// container, and a view placed directly into a window that is released at the end of a test + /// leaves the test process crashing 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: the foreground notification posted here must not reach the SDK's own observers or /// the tests running alongside. private let notificationCenter = NotificationCenter() @@ -164,7 +173,7 @@ struct EmbeddedBlockShimmerViewTests { #expect(sweep(of: shimmer) == nil) - window.addSubview(shimmer) + host.addSubview(shimmer) #expect(sweep(of: shimmer) != nil) shimmer.removeFromSuperview() @@ -174,7 +183,7 @@ struct EmbeddedBlockShimmerViewTests { @Test("A start while already sweeping never stacks a second sweep") func repeatedStartKeepsOneSweep() { let shimmer = makeShimmer() - window.addSubview(shimmer) + host.addSubview(shimmer) // The foreground notification is a second start request on a shimmer already on screen. enterForeground() @@ -186,7 +195,7 @@ struct EmbeddedBlockShimmerViewTests { @Test("Returning to the foreground restarts a sweep the system has dropped") func foregroundRestartsADroppedSweep() { let shimmer = makeShimmer() - window.addSubview(shimmer) + host.addSubview(shimmer) // The system removes infinite animations while the app is in the background. shimmer.gradientLayer.removeAllAnimations() #expect(sweep(of: shimmer) == nil) @@ -211,11 +220,11 @@ struct EmbeddedBlockShimmerViewTests { func shimmersShareOneBeat() throws { let first = makeShimmer() let second = makeShimmer() - window.addSubview(first) + host.addSubview(first) let firstSweep = try #require(sweep(of: first)) // The second one comes later, mid-cycle of the first. - window.addSubview(second) + host.addSubview(second) let secondSweep = try #require(sweep(of: second)) #expect(firstSweep.beginTime == secondSweep.beginTime) @@ -231,11 +240,11 @@ struct EmbeddedBlockShimmerViewTests { @Test("Re-entering a window keeps the beat rather than starting a cycle of its own") func reEnteringAWindowKeepsTheBeat() throws { let shimmer = makeShimmer() - window.addSubview(shimmer) + host.addSubview(shimmer) let before = try #require(sweep(of: shimmer)).beginTime shimmer.removeFromSuperview() - window.addSubview(shimmer) + host.addSubview(shimmer) let after = try #require(sweep(of: shimmer)).beginTime #expect(before == after) From ceac3265ea1eb2c26e2102eac60f0f490143f39c Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 8 Sep 2026 03:13:17 +0500 Subject: [PATCH 5/6] MOBILE-438: Drop the shimmer's internal doc comments --- .../Container/EmbeddedBlockShimmerView.swift | 87 +------------------ .../EmbeddedBlockShimmerViewTests.swift | 13 +-- 2 files changed, 5 insertions(+), 95 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index 2319e0e2..24c803a5 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -8,86 +8,29 @@ import UIKit -/// The default embedded block placeholder — the Mindbox UI Library "Shimmer": a translucent tint -/// over the host's own background with a highlight sweeping across it. -/// -/// 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`. -/// -/// The design (Figma, Mobile Launchpad → "Шиммер вью для встроенных блоков") in one paragraph: the -/// shimmer is a mask, not a tile. It paints no base color of its own — a single tint (near-black in -/// light appearance, white in dark) at 8% opacity, so the same view reads on a white screen, a -/// brand color and a dark theme alike. The highlight is the only place where that opacity changes: -/// it dips to 4% in light (a lighter spot) and peaks at 16% in dark (a brighter spot). Every size -/// is a fraction of the block's width, never a point value: the same file serves an 80-point -/// avatar and a full-width banner. -/// -/// Several shimmers on one screen move to one beat: each animation is anchored to a clock shared -/// by the whole process, so a block that appears later joins the sweep already in progress instead -/// of starting its own. The beat holds as long as the host leaves the layer clock alone: an ancestor -/// layer with its own `speed` or `timeOffset` shifts the shimmers under it. -/// -/// What is deliberately left out is the design's layer blur. It is about 5.6% of the block's -/// width — under 2% of the gradient layer, about a tenth of the ramp it would soften — and it -/// would move an opacity that is 8% to begin with by a few hundredths. Core Animation offers no -/// public blur for a layer on iOS, and the result would be indistinguishable from the plain -/// linear ramp anyway. final class EmbeddedBlockShimmerView: UIView { - /// The design's numbers. Positions are fractions of the block's width (`W`). + /// Figma: Mobile Launchpad → «Шиммер вью для встроенных блоков». Positions are fractions of the block's width. enum Design { - // MARK: Color - - /// The light-appearance tint, `#282A2F`; its opacity does the work. static let lightTint = UIColor(red: 0x28 / 255.0, green: 0x2A / 255.0, blue: 0x2F / 255.0, alpha: 1.0) - - /// The dark-appearance tint, `#FFFFFF`. static let darkTint = UIColor.white - /// The tint's opacity everywhere but the highlight, both appearances. static let restingAlpha: CGFloat = 0.08 - - /// The highlight in light appearance dips — a spot lighter than the base. static let lightHighlightAlpha: CGFloat = 0.04 - - /// The highlight in dark appearance peaks — a spot brighter than the base. static let darkHighlightAlpha: CGFloat = 0.16 - // MARK: Geometry - - /// Where the tint changes opacity, as fractions of the gradient layer's width: flat, a - /// ramp into the highlight between 40% and 60%, flat again. static let stops: [CGFloat] = [0.0, 0.4, 0.5, 0.6, 1.0] - - /// The gradient layer is wider than the block so that both rest positions keep the whole - /// ramp out of sight and the block shows a flat 8% at either end of the cycle. static let layerWidth: CGFloat = 2.96 - - /// The gradient layer's leading edge at rest before the sweep, in `W`. static let startX: CGFloat = -1.88 - - /// The gradient layer's leading edge at rest after the sweep, in `W`. static let endX: CGFloat = -0.083 - // MARK: Timing - - /// Flat at the start position before the highlight sets off. static let pauseAtStart: CFTimeInterval = 0.6 - - /// The sweep itself, ease-in: the highlight leaves slowly and exits fast. static let sweepDuration: CFTimeInterval = 1.0 - - /// Flat at the end position; then the layer jumps back to the start. Both rest positions - /// look identical, so the jump is invisible and the way back is not animated. static let pauseAtEnd: CFTimeInterval = 0.6 static var cycleDuration: CFTimeInterval { pauseAtStart + sweepDuration + pauseAtEnd } - /// The stops in the block's own coordinates: `0` is its leading edge, `1` its trailing one. - /// Moving the gradient layer by `x` is the same as shifting every stop by `x` — and stops - /// are unit-less, so the animation never has to be rebuilt when the block is laid out. static func locations(forLayerAt x: CGFloat) -> [CGFloat] { stops.map { x + layerWidth * $0 } } @@ -96,7 +39,6 @@ final class EmbeddedBlockShimmerView: UIView { static var endLocations: [CGFloat] { locations(forLayerAt: endX) } - /// The tint's opacity at each stop for the given appearance. static func alphas(isDark: Bool) -> [CGFloat] { let highlight = isDark ? darkHighlightAlpha : lightHighlightAlpha return [restingAlpha, restingAlpha, highlight, restingAlpha, restingAlpha] @@ -108,29 +50,19 @@ final class EmbeddedBlockShimmerView: UIView { } } - // MARK: - Shared beat - - /// The instant every shimmer counts its cycle from. One per process: two blocks side by side - /// — or a block that shows up a screen later — are at the same point of the same cycle. + /// One per process so every shimmer on screen sweeps in the same phase. static let beatEpoch: CFTimeInterval = CACurrentMediaTime() - // MARK: - State - static let animationKey = "embeddedBlockShimmer" let gradientLayer = CAGradientLayer() private let notificationCenter: NotificationCenter - /// `.unspecified` and `.light` alike take the light tint; on iOS 12 the style is always `.light`. private var isDarkAppearance: Bool { traitCollection.userInterfaceStyle == .dark } - // MARK: - Life cycle - - /// - Parameter notificationCenter: Where the app's foreground notification comes from. Injected - /// so that tests do not have to post to the process-wide center. init(frame: CGRect = .zero, notificationCenter: NotificationCenter = .default) { self.notificationCenter = notificationCenter super.init(frame: frame) @@ -146,10 +78,9 @@ final class EmbeddedBlockShimmerView: UIView { notificationCenter.removeObserver(self) } - /// A resize snaps: the implicit 0.25 s action a standalone sublayer gets would make the tint - /// grow into new bounds instead of filling them at once. override func layoutSubviews() { super.layoutSubviews() + // Without this a resize animates the layer's frame over 0.25 s. CATransaction.begin() CATransaction.setDisableActions(true) gradientLayer.frame = bounds @@ -173,7 +104,6 @@ final class EmbeddedBlockShimmerView: UIView { private func setUp() { isUserInteractionEnabled = false - // A mask over the host's background: nothing of its own underneath the tint. backgroundColor = .clear gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) @@ -192,8 +122,6 @@ final class EmbeddedBlockShimmerView: UIView { gradientLayer.colors = Design.colors(isDark: isDarkAppearance) } - // MARK: - Animation - private func startShimmering() { guard gradientLayer.animation(forKey: Self.animationKey) == nil else { return } @@ -205,11 +133,6 @@ final class EmbeddedBlockShimmerView: UIView { gradientLayer.removeAnimation(forKey: Self.animationKey) } - /// One cycle of the design, repeated forever: rest, sweep, rest, jump back. - /// - /// `beginTime` lies in the past for every shimmer but the very first: Core Animation then picks - /// the cycle up at the phase the shared clock dictates rather than from the start, which is what - /// keeps every shimmer on screen in step. 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)) } @@ -234,9 +157,7 @@ final class EmbeddedBlockShimmerView: UIView { return animation } - /// The system removes infinite CA animations when the app goes to the background — after - /// coming back the sweep has to be started again. Anchored to the shared clock, it comes back - /// at the right phase. + // The system drops infinite animations while the app is in the background. @objc private func applicationWillEnterForeground() { guard window != nil else { return } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift index 8c09a7e5..09875dd2 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift @@ -10,8 +10,6 @@ import Testing import UIKit @testable import Mindbox -/// The stock placeholder against its design: a mask of one tint whose opacity alone changes, sized -/// in fractions of the block, sweeping once per cycle, and in step with every other shimmer around. @Suite("Embedded block shimmer", .tags(.embeddedBlocks)) @MainActor struct EmbeddedBlockShimmerViewTests { @@ -20,17 +18,13 @@ struct EmbeddedBlockShimmerViewTests { private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) - /// The shimmer goes here, not straight into the window: in the SDK it always sits inside the block - /// container, and a view placed directly into a window that is released at the end of a test - /// leaves the test process crashing on the next run-loop turn. + // 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: the foreground notification posted here must not reach the SDK's own observers or - /// the tests running alongside. private let notificationCenter = NotificationCenter() private func makeShimmer() -> EmbeddedBlockShimmerView { @@ -115,13 +109,11 @@ struct EmbeddedBlockShimmerViewTests { @Test("At both rest positions the ramp lies outside the block, which shows a flat tint") func rampIsHiddenAtBothRestPositions() { - // The ramp is the three middle stops; the outer two are flat and may be anywhere. 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") - // And the flat outer stops still cover the block from both sides at either rest. #expect(Design.startLocations.first! < 0 && Design.startLocations.last! > 1) #expect(Design.endLocations.first! < 0 && Design.endLocations.last! > 1) } @@ -185,7 +177,6 @@ struct EmbeddedBlockShimmerViewTests { let shimmer = makeShimmer() host.addSubview(shimmer) - // The foreground notification is a second start request on a shimmer already on screen. enterForeground() enterForeground() @@ -196,7 +187,6 @@ struct EmbeddedBlockShimmerViewTests { func foregroundRestartsADroppedSweep() { let shimmer = makeShimmer() host.addSubview(shimmer) - // The system removes infinite animations while the app is in the background. shimmer.gradientLayer.removeAllAnimations() #expect(sweep(of: shimmer) == nil) @@ -223,7 +213,6 @@ struct EmbeddedBlockShimmerViewTests { host.addSubview(first) let firstSweep = try #require(sweep(of: first)) - // The second one comes later, mid-cycle of the first. host.addSubview(second) let secondSweep = try #require(sweep(of: second)) From a96311494e2a7dc2207e5936cc2bea3cecd0f2ce Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 8 Sep 2026 15:19:05 +0500 Subject: [PATCH 6/6] MOBILE-438: Disable the gradient layer's implicit actions once instead of per layout --- .../Container/EmbeddedBlockShimmerView.swift | 7 ++----- .../EmbeddedBlockShimmerViewTests.swift | 13 +++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index 24c803a5..5706c111 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -10,7 +10,7 @@ import UIKit final class EmbeddedBlockShimmerView: UIView { - /// Figma: Mobile Launchpad → «Шиммер вью для встроенных блоков». Positions are fractions of the block's width. + /// Positions are fractions of the block's width. enum Design { static let lightTint = UIColor(red: 0x28 / 255.0, green: 0x2A / 255.0, blue: 0x2F / 255.0, alpha: 1.0) @@ -80,11 +80,7 @@ final class EmbeddedBlockShimmerView: UIView { override func layoutSubviews() { super.layoutSubviews() - // Without this a resize animates the layer's frame over 0.25 s. - CATransaction.begin() - CATransaction.setDisableActions(true) gradientLayer.frame = bounds - CATransaction.commit() } override func didMoveToWindow() { @@ -106,6 +102,7 @@ final class EmbeddedBlockShimmerView: UIView { 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 = Design.startLocations.map { NSNumber(value: Double($0)) } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift index 09875dd2..3a709e28 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockShimmerViewTests.swift @@ -96,6 +96,19 @@ struct EmbeddedBlockShimmerViewTests { #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")