diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift index 2437c4d..60f24b6 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+AnimateLabView.swift @@ -42,7 +42,13 @@ extension Playground { /// An interactive page for exercising the `CALayer` animate APIs directly. /// - /// The box is a plain sublayer outside of the render pass's management, animated only by the animate APIs. The toggle + /// The stage hosts two boxes outside of the render pass's management, animated only by the animate APIs: + /// - The top, blue-gray box is a plain `CALayer`. + /// - The bottom, green box is a plain layer-backed `View`, driven through its backing layer. This additionally + /// exercises the animate APIs' `backedView` model sync (the view's `frame` on macOS and `alpha` on both platforms), + /// which a plain layer never hits. + /// + /// Every action dispatches the same animation to both boxes, so they should visibly move in lockstep. The toggle /// buttons drive a single animation each (immediate or with a 1s delay), and the scenario buttons run scripted /// sequences with fixed internal timings, so a session on one build can be compared with a session on another, /// visually and through the logged samples. @@ -51,31 +57,30 @@ extension Playground { /// - A delayed animation shows the old value during the delay window, then animates. /// - When the model value changes relative to the visible change (the sample logs both). /// - How a delayed animation composes with an in-flight one, and the final resting values. + /// - The view box stays in lockstep with the layer box, and its `frame`/`alpha` stay in sync with its layer's model + /// values (the view sample logs both). final class AnimateLabView: ComposeView { - private enum Constants { - static let boxSize: CGFloat = 48 - static let boxMargin: CGFloat = 20 - static let duration: TimeInterval = 2.5 - static let delay: TimeInterval = 1 - static let fadedOpacity: Float = 0.15 - static let cornerRadiusNormal: CGFloat = 6 - static let cornerRadiusRounded: CGFloat = 24 - } - - /// The stage layer hosting the box. The box is positioned in the stage's coordinates once the stage has a size, and - /// is otherwise fully owned by the animate calls. - private let stageLayer = CALayer() + /// The stage view hosting the boxes. The boxes are positioned in the stage's coordinates once the stage has a size, + /// and are otherwise fully owned by the animate calls. The stage is a view (not a layer) because the view box needs + /// a view parent. `BaseView` is flipped on macOS, so both platforms use identical geometry. + private let stageView = BaseView() + /// The layer box, on the top lane. private let boxLayer = CALayer() - private var isBoxPositioned = false + + /// The view box, on the bottom lane. Animated through its backing layer. + private let boxView = BaseView() + + private var areBoxesPositioned = false private var isMovedRight = false private var isFaded = false private var isRounded = false private var samplingTimer: Timer? - private var lastSampleLine: String? + private var lastLayerSampleLine: String? + private var lastViewSampleLine: String? private typealias Debug = Playground.Debug @@ -89,17 +94,18 @@ extension Playground { @ComposeContentBuilder override var content: ComposeContent { VStack(spacing: 10) { - LayerNode( - make: { [weak self] _ in self?.stageLayer ?? CALayer() }, + ViewNode( + make: { [weak self] _ in self?.stageView ?? BaseView() }, update: { [weak self] _, context in - self?.positionBoxIfNeeded(stageSize: context.newFrame.size) + self?.positionBoxesIfNeeded(stageSize: context.newFrame.size) + self?.updateBoxNameLabels() } ) .underlay { LayerNode() .border(color: Color.gray, width: 1) } - .frame(width: .flexible, height: 100) + .frame(width: .flexible, height: Constants.stageHeight) HStack(spacing: 10) { Playground.button(title: "Move ⇄", fontSize: 11) { [weak self] in @@ -155,61 +161,105 @@ extension Playground { (0.6, "fade back delayed", { self?.fade(delayed: true) }), ]) } - Playground.button(title: "Reset", fontSize: 11) { [weak self] in - self?.tap("Reset") { self?.reset() } - } } .frame(width: .flexible, height: 32) + + Playground.button(title: "Reset", fontSize: 11) { [weak self] in + self?.tap("Reset") { self?.reset() } + } + .frame(width: 120, height: 32) } .padding(12) } override init(frame: CGRect) { super.init(frame: frame) + clippingBehavior = .always } // MARK: - Actions - private static func homeFrame(in stageBounds: CGRect) -> CGRect { - CGRect( + /// The view box's backing layer, which the animate calls drive. + private var boxViewLayer: CALayer { + #if canImport(AppKit) + return boxView.layer! // swiftlint:disable:this force_unwrapping + #else + return boxView.layer + #endif + } + + /// The home frame for a lane, with lane 0 at the top. The lanes are vertically centered in the stage. + private static func homeFrame(lane: Int, in stageBounds: CGRect) -> CGRect { + let lanesHeight = Constants.boxSize * 2 + Constants.laneSpacing + let topY = (stageBounds.height - lanesHeight) / 2 + return CGRect( x: Constants.boxMargin, - y: (stageBounds.height - Constants.boxSize) / 2, + y: topY + CGFloat(lane) * (Constants.boxSize + Constants.laneSpacing), width: Constants.boxSize, height: Constants.boxSize ) } - /// Positions the box at its home frame once the stage has a size. - private func positionBoxIfNeeded(stageSize: CGSize) { - guard !isBoxPositioned, stageSize.width > 0 else { + /// The target frame for a lane's box, based on the current moved state. + private func targetFrame(lane: Int, in stageBounds: CGRect) -> CGRect { + var frame = Self.homeFrame(lane: lane, in: stageBounds) + if isMovedRight { + frame.origin.x = stageBounds.width - Constants.boxSize - Constants.boxMargin + } + return frame + } + + /// Positions the boxes at their home frames once the stage has a size. + private func positionBoxesIfNeeded(stageSize: CGSize) { + guard !areBoxesPositioned, stageSize.width > 0 else { return } - isBoxPositioned = true + areBoxesPositioned = true + #if canImport(AppKit) + let stageLayer = stageView.layer! // swiftlint:disable:this force_unwrapping + #else + let stageLayer = stageView.layer + #endif stageLayer.masksToBounds = true stageLayer.addSublayer(boxLayer) + stageView.addSubview(boxView) CATransaction.begin() CATransaction.setDisableActions(true) - boxLayer.frame = Self.homeFrame(in: CGRect(origin: .zero, size: stageSize)) + let stageBounds = CGRect(origin: .zero, size: stageSize) + boxLayer.frame = Self.homeFrame(lane: 0, in: stageBounds) boxLayer.backgroundColor = Colors.blueGray.cgColor boxLayer.cornerRadius = Constants.cornerRadiusNormal + + boxView.frame = Self.homeFrame(lane: 1, in: stageBounds) + boxViewLayer.backgroundColor = Colors.RetroApple.green.cgColor + boxViewLayer.cornerRadius = Constants.cornerRadiusNormal CATransaction.commit() } + /// Applies the boxes' name labels, keeping their contents scale in sync with the current display. + private func updateBoxNameLabels() { + guard areBoxesPositioned else { + return + } + let scale = Playground.displayScale(of: self) + Playground.addBoxNameLabel("layer", to: boxLayer, scale: scale) + Playground.addBoxNameLabel("view", to: boxViewLayer, scale: scale) + } + private func timing(delayed: Bool) -> AnimationTiming { .easeInEaseOut(duration: Constants.duration, delay: delayed ? Constants.delay : 0) } private func move(delayed: Bool) { isMovedRight.toggle() - var targetFrame = Self.homeFrame(in: stageLayer.bounds) - if isMovedRight { - targetFrame.origin.x = stageLayer.bounds.width - Constants.boxSize - Constants.boxMargin - } - log("DISPATCH animateFrame(to: \(Debug.format(targetFrame.origin)), delay: \(delayed ? Constants.delay : 0))") - boxLayer.animateFrame(to: targetFrame, timing: timing(delayed: delayed)) + let stageBounds = stageView.bounds + // the lanes share the same x, log it once for both boxes + log("DISPATCH animateFrame(to x: \(Debug.format(targetFrame(lane: 0, in: stageBounds).origin.x)), delay: \(delayed ? Constants.delay : 0))") + boxLayer.animateFrame(to: targetFrame(lane: 0, in: stageBounds), timing: timing(delayed: delayed)) + boxViewLayer.animateFrame(to: targetFrame(lane: 1, in: stageBounds), timing: timing(delayed: delayed)) } private func fade(delayed: Bool) { @@ -217,6 +267,7 @@ extension Playground { let targetOpacity: Float = isFaded ? Constants.fadedOpacity : 1 log("DISPATCH animate(opacity, to: \(Debug.format(targetOpacity)), delay: \(delayed ? Constants.delay : 0))") boxLayer.animate(keyPath: "opacity", to: targetOpacity, timing: timing(delayed: delayed)) + boxViewLayer.animate(keyPath: "opacity", to: targetOpacity, timing: timing(delayed: delayed)) } private func corner(delayed: Bool) { @@ -224,17 +275,25 @@ extension Playground { let targetRadius = isRounded ? Constants.cornerRadiusRounded : Constants.cornerRadiusNormal log("DISPATCH animate(cornerRadius, to: \(Debug.format(targetRadius)), delay: \(delayed ? Constants.delay : 0))") boxLayer.animate(keyPath: "cornerRadius", to: targetRadius, timing: timing(delayed: delayed)) + boxViewLayer.animate(keyPath: "cornerRadius", to: targetRadius, timing: timing(delayed: delayed)) } - /// Restores the box to its home state with no animations, so scenario runs start from the same state. + /// Restores the boxes to their home state with no animations, so scenario runs start from the same state. private func reset() { scenarioToken = UUID() boxLayer.removeAllAnimations() + boxViewLayer.removeAllAnimations() CATransaction.begin() CATransaction.setDisableActions(true) - boxLayer.frame = Self.homeFrame(in: stageLayer.bounds) + let stageBounds = stageView.bounds + boxLayer.frame = Self.homeFrame(lane: 0, in: stageBounds) boxLayer.opacity = 1 boxLayer.cornerRadius = Constants.cornerRadiusNormal + + // reset the view box through the view's properties so the view model and the layer stay in sync on macOS + boxView.frame = Self.homeFrame(lane: 1, in: stageBounds) + boxView.alpha = 1 + boxViewLayer.cornerRadius = Constants.cornerRadiusNormal CATransaction.commit() isMovedRight = false isFaded = false @@ -244,7 +303,7 @@ extension Playground { // MARK: - Scenarios - /// Resets the box, then runs the steps at their fixed offsets, logging each one. + /// Resets the boxes, then runs the steps at their fixed offsets, logging each one. /// /// Starting a new scenario (or tapping any other button) cancels the previous scenario's remaining steps. private func runScenario(_ name: String, steps: [(offset: TimeInterval, name: String, action: () -> Void)]) { @@ -273,6 +332,20 @@ extension Playground { action() } + // MARK: - Constants + + private enum Constants { + static let stageHeight: CGFloat = 120 + static let boxSize: CGFloat = 48 + static let boxMargin: CGFloat = 20 + static let laneSpacing: CGFloat = 8 + static let duration: TimeInterval = 2.5 + static let delay: TimeInterval = 1 + static let fadedOpacity: Float = 0.15 + static let cornerRadiusNormal: CGFloat = 6 + static let cornerRadiusRounded: CGFloat = 24 + } + // MARK: - Sampling #if canImport(UIKit) @@ -304,24 +377,31 @@ extension Playground { return } let timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in - self?.sampleBoxState() + self?.sampleBoxStates() } RunLoop.main.add(timer, forMode: .common) samplingTimer = timer } - /// Logs the box layer's state when it changed since the last sample. - private func sampleBoxState() { - let line = describeBox() - guard line != lastSampleLine else { - return + /// Logs each box's state when it changed since the last sample. + /// + /// The boxes are sampled independently so a divergence between them shows up as one box logging without the other. + /// The view box's sample includes the view's `frame` and `alpha`, which should track the layer's model values. + private func sampleBoxStates() { + let layerLine = describeBox(boxLayer) + if layerLine != lastLayerSampleLine { + lastLayerSampleLine = layerLine + log("SAMPLE(layer) \(layerLine)") + } + + let viewLine = "frame = \(Debug.format(boxView.frame)), alpha = \(Debug.format(boxView.alpha)), \(describeBox(boxViewLayer))" + if viewLine != lastViewSampleLine { + lastViewSampleLine = viewLine + log("SAMPLE(view) \(viewLine)") } - lastSampleLine = line - log("SAMPLE \(line)") } - private func describeBox() -> String { - let layer = boxLayer + private func describeBox(_ layer: CALayer) -> String { let model = "position = \(Debug.format(layer.position)), opacity = \(Debug.format(layer.opacity)), corner = \(Debug.format(layer.cornerRadius))" let presentation = layer.presentation().map { "presentation: position = \(Debug.format($0.position)), opacity = \(Debug.format($0.opacity)), corner = \(Debug.format($0.cornerRadius))" diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift index fcbf13a..1364561 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+Debug.swift @@ -95,10 +95,77 @@ extension Playground { static func format(_ point: CGPoint) -> String { String(format: "(%.1f, %.1f)", point.x, point.y) } + + static func format(_ rect: CGRect) -> String { + String(format: "(%.1f, %.1f, %.1f, %.1f)", rect.origin.x, rect.origin.y, rect.width, rect.height) + } + } + + /// Adds a small centered name label to a box layer, so the box's renderable kind is identifiable on screen. + /// + /// - Parameters: + /// - name: The name to show on the box. + /// - layer: The box's layer. + /// - scale: The label's contents scale, see `displayScale(of:)`. + static func addBoxNameLabel(_ name: String, to layer: CALayer, scale: CGFloat) { + let labelLayerName = "box-name-label" + + let textLayer: CATextLayer + if let existing = layer.sublayers?.first(where: { $0.name == labelLayerName }) as? CATextLayer { + textLayer = existing + } else { + textLayer = CATextLayer() + textLayer.name = labelLayerName + textLayer.string = name + // UIFont/NSFont is toll-free bridged to the CTFont that CATextLayer expects + textLayer.font = Font.systemFont(ofSize: BoxNameStyle.fontSize, weight: .medium) + textLayer.fontSize = BoxNameStyle.fontSize + textLayer.foregroundColor = Color.white.cgColor + textLayer.alignmentMode = .center + layer.addSublayer(textLayer) + } + + let frame = CGRect( + x: 0, + y: (layer.bounds.height - BoxNameStyle.height) / 2, + width: layer.bounds.width, + height: BoxNameStyle.height + ) + let needsScaleUpdate = textLayer.contentsScale != scale + let needsFrameUpdate = textLayer.frame != frame + if needsScaleUpdate || needsFrameUpdate { + CATransaction.begin() + CATransaction.setDisableActions(true) + if needsScaleUpdate { + textLayer.contentsScale = scale + } + if needsFrameUpdate { + textLayer.frame = frame + } + CATransaction.commit() + } + } + + /// The display scale of the view's environment, for crisp layer text. + /// + /// - Parameter view: The view whose window or screen provides the scale. + /// - Returns: The display scale. + static func displayScale(of view: View) -> CGFloat { + #if canImport(AppKit) + return view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? BoxNameStyle.fallbackDisplayScale + #else + let scale = view.traitCollection.displayScale + // an unattached view can report an unspecified (0) display scale, fall back to a retina scale + return scale > 0 ? scale : BoxNameStyle.fallbackDisplayScale + #endif } /// Makes a standard playground action button. /// + /// The button renders as a raised, bordered rounded rect that flattens while pressed, so it reads as a control next + /// to the pages' plain color boxes. The title is bounded by the button's width and truncates instead of overflowing + /// into neighboring buttons. + /// /// - Parameters: /// - title: The button title. /// - fontSize: The title's font size. `nil` uses the label's default font. @@ -117,19 +184,103 @@ extension Playground { case .disabled: backgroundColor = Colors.lightBlueGray } + + // a pressed button sits flat: the lift shadow and the bevel highlight are hidden (via opacity, so the node + // structure is stable across state changes), leaving the darker background as the pressed look + let isPressed = state == .pressed || state == .selected + var label = Label(title) .textColor(.white) .selectable(false) + // bound the title to the button's width, so a long title truncates instead of painting over neighbors + .fixedSize(width: false, height: true) if let fontSize { label = label.font(.systemFont(ofSize: fontSize)) } + ColorNode(backgroundColor) - .cornerRadius(6) + .cornerRadius(ButtonStyle.cornerRadius) + .border(color: ButtonStyle.borderColor, width: 1) + .overlay { + // a top inner highlight, inset to sit within the border, gives the button a raised, bevelled face + InnerShadowNode( + color: .white, + opacity: isPressed ? 0 : ButtonStyle.bevelOpacity, + radius: 0, + offset: CGSize(width: 0, height: 1), + path: { renderable in + let size = renderable.frame.size + let cornerRadius = ButtonStyle.cornerRadius - 1 + return CGPath( + roundedRect: CGRect(x: 0, y: 0, width: size.width, height: size.height), + cornerWidth: cornerRadius, + cornerHeight: cornerRadius, + transform: nil + ) + } + ) + .padding(1) + } + .dropShadow( + color: .black, + opacity: isPressed ? 0 : ButtonStyle.shadowOpacity, + radius: ButtonStyle.shadowRadius, + offset: ButtonStyle.shadowOffset, + path: { renderable in + let size = renderable.frame.size + return CGPath( + roundedRect: CGRect(x: 0, y: 0, width: size.width, height: size.height), + cornerWidth: ButtonStyle.cornerRadius, + cornerHeight: ButtonStyle.cornerRadius, + transform: nil + ) + } + ) .overlay { - label + label.padding(horizontal: ButtonStyle.titlePadding) } }, onTap: onTap ) } } + +// MARK: - Constants + +/// The shared style values for the box name labels. +private enum BoxNameStyle { + + /// The font size of the name label. + static let fontSize: CGFloat = 11 + + /// The height of the name label. + static let height: CGFloat = 14 + + /// The display scale to use when the view's environment doesn't provide one. + static let fallbackDisplayScale: CGFloat = 2 +} + +/// The shared style values for `Playground.button`. +private enum ButtonStyle { + + /// The button's corner radius. + static let cornerRadius: CGFloat = 6 + + /// The border color, translucent black so it works with all state background colors. + static let borderColor = Color(white: 0, alpha: 0.2) + + /// The opacity of the top bevel highlight. + static let bevelOpacity: CGFloat = 0.3 + + /// The opacity of the lift shadow under the button. + static let shadowOpacity: CGFloat = 0.25 + + /// The blur radius of the lift shadow. + static let shadowRadius: CGFloat = 1.5 + + /// The offset of the lift shadow. + static let shadowOffset = CGSize(width: 0, height: 1) + + /// The horizontal padding between the title and the button's edges. + static let titlePadding: CGFloat = 6 +} diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift index 1c6d48d..bc7531d 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/PlaygroundViews/Playground+TransitionRevivalView.swift @@ -42,14 +42,20 @@ extension Playground { /// An interactive insert/remove page for verifying transition revivals. /// - /// The transitions run slowly so a removal can be interrupted mid-flight, and the transition button cycles - /// through a fade, which retargets from the interrupted opacity, and two slide configurations, which continue the - /// interrupted motion from wherever the removal left it and differ only in their entry and exit sides. + /// The page inserts and removes two boxes together, so a revival is verified on both renderable kinds: + /// - The top, blue-gray box is a layer renderable. + /// - The bottom, green box is a view renderable, whose transitions additionally exercise the `backedView` model sync + /// (the view's `frame` on macOS and `alpha` on both platforms). + /// + /// The transitions run slowly so a removal can be interrupted mid-flight, and the transition button cycles through a + /// fade, which retargets from the interrupted opacity, and two slide configurations, which continue the interrupted + /// motion from wherever the removal left it and differ only in their entry and exit sides. /// A non-animated re-insert always snaps to the resting state. /// - /// The page logs button taps, the box renderable's lifecycle events, and a continuous sample of - /// the box layer's model and presentation values, so a manual test session can be diagnosed from - /// the console output. + /// The page logs button taps, each box renderable's lifecycle events, and a continuous sample of each box layer's + /// model and presentation values, so a manual test session can be diagnosed from the console output. The boxes are + /// sampled independently, so a divergence between the layer box and the view box shows up as one box logging without + /// the other. final class TransitionRevivalView: ComposeView { /// The transition to verify, cycled through by the page's transition button. @@ -111,13 +117,16 @@ extension Playground { private var isShowing = true private var transitionKind: TransitionKind = .opacity - private weak var boxLayer: CALayer? + private weak var layerBoxLayer: CALayer? + private weak var viewBoxView: View? + private weak var viewBoxLayer: CALayer? private var samplingTimer: Timer? - private var lastSampleLine: String? + private var lastLayerSampleLine: String? + private var lastViewSampleLine: String? private typealias Debug = Playground.Debug - /// Whether the box layer can be tracked, which requires the content view's DEBUG-only debug events. + /// Whether the box layers can be tracked, which requires the content view's DEBUG-only debug events. private var isSamplingSupported: Bool { #if DEBUG return true @@ -129,13 +138,43 @@ extension Playground { @ComposeContentBuilder override var content: ComposeContent { VStack(spacing: 12) { - VStack { + VStack(spacing: Constants.laneSpacing) { if isShowing { - ColorNode(Colors.blueGray) - .transition(transitionKind.transition) - .frame(width: 160, height: 64) - .id("box") - .frame(.flexible, alignment: .center) + // the layer lane: the transition drives a plain layer renderable. + // the box's name is a sublayer (instead of an overlay node) so the box stays a single renderable for the + // lifecycle logging, and the name rides along during transitions. + LayerNode( + update: { [weak self] layer, _ in + guard let self else { + return + } + layer.backgroundColor = Colors.blueGray.cgColor + Playground.addBoxNameLabel("layer", to: layer, scale: Playground.displayScale(of: self)) + } + ) + .transition(transitionKind.transition) + .frame(Constants.boxSize) + .id("layer-box") + + // the view lane: the transition drives a view renderable, which additionally exercises the view model sync + // (frame/alpha) + ViewNode( + update: { [weak self] view, _ in + guard let self else { + return + } + #if canImport(AppKit) + let boxLayer = view.layer! // swiftlint:disable:this force_unwrapping + #else + let boxLayer = view.layer + #endif + boxLayer.backgroundColor = Colors.RetroApple.green.cgColor + Playground.addBoxNameLabel("view", to: boxLayer, scale: Playground.displayScale(of: self)) + } + ) + .transition(transitionKind.transition) + .frame(Constants.boxSize) + .id("view-box") } else { Empty() } @@ -143,7 +182,8 @@ extension Playground { .frame(width: .flexible, height: .flexible) HStack(spacing: 12) { - Playground.button(title: isShowing ? "Remove (animated)" : "Insert (animated)") { [weak self] in + // 14pt so the longest title, "Remove (animated)", fits the half-width button on compact screens + Playground.button(title: isShowing ? "Remove (animated)" : "Insert (animated)", fontSize: 14) { [weak self] in guard let self else { return } @@ -153,7 +193,7 @@ extension Playground { self.logBoxState("after refresh(animated: true)") } - Playground.button(title: isShowing ? "Remove (instant)" : "Insert (instant)") { [weak self] in + Playground.button(title: isShowing ? "Remove (instant)" : "Insert (instant)", fontSize: 14) { [weak self] in guard let self else { return } @@ -165,7 +205,7 @@ extension Playground { } .frame(width: .flexible, height: 36) - Playground.button(title: "Transition: \(transitionKind.title)") { [weak self] in + Playground.button(title: "Transition: \(transitionKind.title)", fontSize: 14) { [weak self] in guard let self else { return } @@ -213,11 +253,15 @@ extension Playground { } private func logBoxEvent(_ name: String, item: RenderableItem, renderable: Renderable) { - guard item.id.id.contains("box") else { - return + let id = item.id.id + if id.contains("layer-box") { + layerBoxLayer = renderable.layer + log("EVENT(layer) \(name), \(describeLayerBox())") + } else if id.contains("view-box") { + viewBoxView = renderable.view + viewBoxLayer = renderable.layer + log("EVENT(view) \(name), \(describeViewBox())") } - boxLayer = renderable.layer - log("EVENT \(name), \(describeBox())") } #endif @@ -239,9 +283,22 @@ extension Playground { samplingTimer?.invalidate() } + // MARK: - Constants + + private enum Constants { + + /// The size of each box lane. + static let boxSize = CGSize(width: 160, height: 64) + + /// The spacing between the two box lanes. + static let laneSpacing: CGFloat = 8 + } + + // MARK: - Sampling + /// Runs the sampling timer while the view is in a window. /// - /// The box layer is tracked through the content view's debug events, which are DEBUG-only, so sampling only has + /// The box layers are tracked through the content view's debug events, which are DEBUG-only, so sampling only has /// something to report in a DEBUG build. private func updateSampling() { guard isSamplingSupported, window != nil else { @@ -259,22 +316,46 @@ extension Playground { samplingTimer = timer } - /// Logs the box layer's state when it changed since the last sample. + /// Logs each box's state when it changed since the last sample. private func sampleBoxState() { - let line = describeBox() - guard line != lastSampleLine else { - return + let layerLine = describeLayerBox() + if layerLine != lastLayerSampleLine { + lastLayerSampleLine = layerLine + log("SAMPLE(layer) \(layerLine)") + } + + let viewLine = describeViewBox() + if viewLine != lastViewSampleLine { + lastViewSampleLine = viewLine + log("SAMPLE(view) \(viewLine)") } - lastSampleLine = line - log("SAMPLE \(line)") } private func logBoxState(_ label: String) { - log("STATE (\(label)) \(describeBox())") + log("STATE(layer) (\(label)) \(describeLayerBox())") + log("STATE(view) (\(label)) \(describeViewBox())") + } + + private func describeLayerBox() -> String { + describeBox(layer: layerBoxLayer) + } + + /// Describes the view box: the view's model values (which should track its layer's model values), then the layer. + private func describeViewBox() -> String { + guard let view = viewBoxView else { + return "box view = nil" + } + let viewModel: String + if transitionKind.animatesPosition { + viewModel = "viewFrame = \(Debug.format(view.frame))" + } else { + viewModel = "viewAlpha = \(Debug.format(view.alpha))" + } + return "\(viewModel), \(describeBox(layer: viewBoxLayer))" } - private func describeBox() -> String { - guard let layer = boxLayer else { + private func describeBox(layer: CALayer?) -> String { + guard let layer else { return "box layer = nil" } let pointer = String(describing: Unmanaged.passUnretained(layer).toOpaque()) diff --git a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift index 3beea83..f332d7c 100644 --- a/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift +++ b/playgrounds/ComposeUIPlayground-iOS/ComposeUIPlayground-iOS/ViewController.swift @@ -97,7 +97,7 @@ class ViewController: UIViewController { .border(color: Color.gray, width: 1) } .padding(horizontal: Constants.padding) - .frame(width: .flexible, height: 300) + .frame(width: .flexible, height: 360) ViewNode() .underlay { diff --git a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift index 62ba306..180f9ad 100644 --- a/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift +++ b/playgrounds/ComposeUIPlayground-macOS/ComposeUIPlayground-macOS/ViewController.swift @@ -94,7 +94,7 @@ class ViewController: NSViewController { .border(color: Color.gray, width: 1) } .padding(horizontal: 16) - .frame(width: .flexible, height: 300) + .frame(width: .flexible, height: 360) Spacer(height: 16)