Created
May 18, 2026 21:44
-
-
Save stephancasas/9e70dca84949fad9fe7909505d26b125 to your computer and use it in GitHub Desktop.
A playground-style SwiftUI view for experimenting with rounded rectangle stroke styles/behaviors in CoreGraphics
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // InteractiveDumbShapeView.swift | |
| // StupidRoundedRectangleStroke | |
| // | |
| // Created by Stephan Casas on 3/4/26. | |
| // | |
| import SwiftUI | |
| struct InteractiveDumbShapeView: View { | |
| // MARK: - Parameters (interactive) | |
| @State private var frameSize: CGFloat = 300 | |
| @State private var cornerRadius: CGFloat = 80 | |
| @State private var lineWidth: CGFloat = 20 | |
| /// Your “expand” that fattens each corner arc segment (adds expand*2 to the quarter-circumference). | |
| @State private var expand: CGFloat = 20 | |
| /// Nudge the dash pattern (useful for “why is this off by a pixel?” debugging). | |
| @State private var dashPhaseNudge: CGFloat = 0 | |
| /// Corner arc offset (you had `- 1` on 3 corners). Keep as a knob. | |
| @State private var cornerArcOffset: CGFloat = 1 | |
| @State private var showGuides: Bool = true | |
| @State private var showInsetPath: Bool = true | |
| @State private var showDiagonals: Bool = true | |
| @State private var currentScale = CGFloat(0) | |
| @State private var finalScale = CGFloat(1) | |
| @State private var currentOffset = CGSize.zero | |
| @State private var finalOffset = CGSize.zero | |
| // MARK: - Body | |
| var body: some View { | |
| VStack { | |
| GeometryReader { geo in | |
| let m = metrics(in: geo.size) | |
| ZStack { | |
| // Background fill | |
| RoundedRectangle(cornerRadius: cornerRadius, style: .circular) | |
| .foregroundStyle(.gray.quaternary) | |
| // Dashed stroke | |
| RoundedRectangle(cornerRadius: cornerRadius, style: .circular) | |
| .strokeBorder( | |
| style: .init( | |
| lineWidth: lineWidth, | |
| lineCap: .round, | |
| dash: m.dash, | |
| dashPhase: m.dashPhase | |
| ) | |
| ) | |
| } | |
| .overlay { | |
| if showGuides { | |
| GuideOverlay( | |
| size: geo.size, | |
| insetFrame: m.insetFrame, | |
| insetRadius: m.insetRadius, | |
| gap: m.gap, | |
| showInsetPath: showInsetPath, | |
| showDiagonals: showDiagonals | |
| ) | |
| } | |
| } | |
| } | |
| .frame(width: frameSize, height: frameSize) | |
| .scaleEffect(finalScale + currentScale) | |
| .offset(CGSize( | |
| width: currentOffset.width + finalOffset.width, | |
| height: currentOffset.height + finalOffset.height | |
| )) | |
| .padding() | |
| .contentShape(Rectangle()) | |
| .gesture( | |
| MagnifyGesture() | |
| .onChanged { value in | |
| currentScale = value.magnification - 1 | |
| } | |
| .onEnded { value in | |
| finalScale += currentScale | |
| currentScale = 0 | |
| } | |
| ) | |
| .gesture(DragGesture() | |
| .onChanged { value in | |
| currentOffset = value.translation | |
| } | |
| .onEnded { _ in | |
| finalOffset = CGSize( | |
| width: finalOffset.width + currentOffset.width, | |
| height: finalOffset.height + currentOffset.height | |
| ) | |
| currentOffset = .zero | |
| } | |
| ) | |
| Spacer() | |
| } | |
| .safeAreaInset(edge: .bottom) { | |
| Controls() | |
| .padding() | |
| .background(.thinMaterial) | |
| } | |
| .toolbar { | |
| ToolbarItemGroup { | |
| Button { | |
| finalScale -= 0.1 | |
| } label: { | |
| Image(systemName: "minus.magnifyingglass") | |
| } | |
| .keyboardShortcut("-", modifiers: .command) | |
| Button { | |
| finalScale += 0.1 | |
| } label: { | |
| Image(systemName: "plus.magnifyingglass") | |
| } | |
| .keyboardShortcut("+", modifiers: .command) | |
| } | |
| ToolbarSpacer() | |
| ToolbarItemGroup { | |
| Button { | |
| finalScale = 1 | |
| finalOffset = .zero | |
| } label: { | |
| Image(systemName: "dot.scope") | |
| } | |
| } | |
| } | |
| .navigationTitle("Dumb Rounded Rectangle Experiment") | |
| .onScrollWheelDelta { x, y in | |
| finalOffset = CGSize(width: finalOffset.width + x, height: finalOffset.height + y) | |
| } | |
| } | |
| // MARK: - Dash metrics | |
| private struct DashMetrics { | |
| var insetFrame: CGRect | |
| var insetRadius: CGFloat | |
| var cornerArc: CGFloat | |
| var gap: CGFloat | |
| var dash: [CGFloat] | |
| var dashPhase: CGFloat | |
| } | |
| private func metrics(in size: CGSize) -> DashMetrics { | |
| let lw = max(0, lineWidth) | |
| let cr = max(0, cornerRadius) | |
| // The stroked path is inset by lw/2. | |
| let insetFrame = CGRect(origin: .zero, size: size).insetBy(dx: lw / 2, dy: lw / 2) | |
| // Match your original math. | |
| let insetRadius = max(0, cr - (lw / 2)) | |
| let cornerCircumference = 2 * CGFloat.pi * insetRadius | |
| let cornerArc = (cornerCircumference / 4) + (expand * 2) | |
| // Vertical segment length on the inset path (minus your expand padding on both ends). | |
| let rawGap = (insetFrame.height - (insetRadius * 2)) - (expand * 2) | |
| let gap = max(0, rawGap) | |
| let offset = lw / 2 | |
| // Your original pattern, but parameterized: | |
| // - first cornerArc uses full value | |
| // - remaining 4 corners use (cornerArc - offset) | |
| // - gaps are slightly different in your original; preserve that behavior | |
| let cornerReduced = max(0, cornerArc - cornerArcOffset) | |
| let dash: [CGFloat] = [ | |
| cornerArc, | |
| gap + (offset / 2), | |
| cornerReduced, // lower-right | |
| gap + cornerArcOffset, | |
| cornerReduced, // lower-left | |
| gap + cornerArcOffset, | |
| cornerReduced, // upper-left | |
| gap + cornerArcOffset, | |
| cornerReduced, // upper-right | |
| gap | |
| ] | |
| let dashPhase = (gap / 2) + cornerArc + (offset / 2) + dashPhaseNudge | |
| return .init( | |
| insetFrame: insetFrame, | |
| insetRadius: insetRadius, | |
| cornerArc: cornerArc, | |
| gap: gap, | |
| dash: dash, | |
| dashPhase: dashPhase | |
| ) | |
| } | |
| // MARK: - Guides | |
| private struct GuideOverlay: View { | |
| let size: CGSize | |
| let insetFrame: CGRect | |
| let insetRadius: CGFloat | |
| let gap: CGFloat | |
| let showInsetPath: Bool | |
| let showDiagonals: Bool | |
| var body: some View { | |
| ZStack { | |
| // Inset Path | |
| RoundedRectangle(cornerRadius: insetRadius, style: .circular) | |
| .strokeBorder(.teal, style: .init(lineWidth: 1)) | |
| .frame(width: insetFrame.width + 1, height: insetFrame.height + 1) | |
| .opacity(showInsetPath ? 1 : 0) | |
| // Origin guides | |
| Rectangle() | |
| .frame(width: 1) | |
| .foregroundStyle(.blue) | |
| .opacity(0.5) | |
| Rectangle() | |
| .frame(height: 1) | |
| .foregroundStyle(.blue) | |
| .opacity(0.5) | |
| // Horizontal guides (purple) | |
| Rectangle() | |
| .frame(width: 1) | |
| .foregroundStyle(.purple) | |
| .opacity(0.5) | |
| .offset(x: xGuideOffset(sign: +1)) | |
| Rectangle() | |
| .frame(width: 1) | |
| .foregroundStyle(.purple) | |
| .opacity(0.5) | |
| .offset(x: xGuideOffset(sign: -1)) | |
| // Vertical guides (orange) | |
| Rectangle() | |
| .frame(height: 1) | |
| .foregroundStyle(.orange) | |
| .opacity(0.5) | |
| .offset(y: yGuideOffset(sign: +1)) | |
| Rectangle() | |
| .frame(height: 1) | |
| .foregroundStyle(.orange) | |
| .opacity(0.5) | |
| .offset(y: yGuideOffset(sign: -1)) | |
| // Diagonal | |
| Rectangle() | |
| .frame(width: size.width * 2, height: 1) | |
| .rotationEffect(.degrees(45)) | |
| .foregroundStyle(.yellow) | |
| .opacity(showDiagonals ? 1 : 0) | |
| Rectangle() | |
| .frame(width: size.width * 2, height: 1) | |
| .rotationEffect(.degrees(-45)) | |
| .foregroundStyle(.yellow) | |
| .opacity(showDiagonals ? 1 : 0) | |
| } | |
| } | |
| private func xGuideOffset(sign: CGFloat) -> CGFloat { | |
| // mirrors your original offset computation, but clearer. | |
| let sideInset = (size.width - insetFrame.width) / 2 | |
| return sign * ((gap / 2) - sideInset) | |
| } | |
| private func yGuideOffset(sign: CGFloat) -> CGFloat { | |
| let sideInset = (size.height - insetFrame.height) / 2 | |
| return sign * ((gap / 2) - sideInset) | |
| } | |
| } | |
| // MARK: - Controls UI | |
| @ViewBuilder | |
| private func Controls() -> some View { | |
| VStack(alignment: .leading, spacing: 12) { | |
| HStack { | |
| Toggle("Guides", isOn: $showGuides) | |
| .toggleStyle(.switch) | |
| .controlSize(.mini) | |
| Spacer() | |
| Toggle("Inset Path", isOn: $showInsetPath).disabled(!showGuides) | |
| Toggle("Diagonals", isOn: $showDiagonals).disabled(!showGuides) | |
| } | |
| Divider() | |
| LabeledSlider("Frame", value: $frameSize, range: 120...500, step: 1) | |
| Divider() | |
| LabeledSlider("Corner Radius", value: $cornerRadius, range: 0...200, step: 1) | |
| LabeledSlider("Line Width", value: $lineWidth, range: 0...80, step: 1) | |
| LabeledSlider("Expand", value: $expand, range: -80...80, step: 1) | |
| Divider() | |
| LabeledSlider("Corner Arc Offset", value: $cornerArcOffset, range: -10...10, step: 0.25) | |
| LabeledSlider("Dash Phase Nudge", value: $dashPhaseNudge, range: -200...200, step: 0.5) | |
| } | |
| .font(.system(size: 13)) | |
| } | |
| } | |
| // Simple reusable slider row. | |
| private struct LabeledSlider: View { | |
| let title: String | |
| @Binding var value: CGFloat | |
| let range: ClosedRange<CGFloat> | |
| let step: CGFloat | |
| init(_ title: String, value: Binding<CGFloat>, range: ClosedRange<CGFloat>, step: CGFloat) { | |
| self.title = title | |
| self._value = value | |
| self.range = range | |
| self.step = step | |
| } | |
| var body: some View { | |
| HStack(spacing: 12) { | |
| Text(title) | |
| .frame(width: 140, alignment: .leading) | |
| Slider( | |
| value: $value, | |
| in: range, | |
| step: step | |
| ) | |
| Text(valueString) | |
| .monospaced() | |
| .frame(width: 70, alignment: .trailing) | |
| } | |
| } | |
| private var valueString: String { | |
| // 2 decimals when step is fractional; otherwise 0. | |
| let decimals = (step.truncatingRemainder(dividingBy: 1) == 0) ? 0 : 2 | |
| return String(format: "%.\(decimals)f", value) | |
| } | |
| } | |
| // MARK: - Preview | |
| #Preview { | |
| InteractiveDumbShapeView() | |
| } | |
| import AppKit | |
| struct ScrollWheelDeltaModifier: ViewModifier { | |
| let onDelta: (CGFloat, CGFloat) -> Void | |
| func body(content: Content) -> some View { | |
| content.background { | |
| ScrollWheelDeltaView(onDelta: onDelta) | |
| .allowsHitTesting(true) | |
| } | |
| } | |
| } | |
| extension View { | |
| /// Reports scroll wheel deltas (dx, dy) on macOS. | |
| func onScrollWheelDelta(_ onDelta: @escaping (CGFloat, CGFloat) -> Void) -> some View { | |
| modifier(ScrollWheelDeltaModifier(onDelta: onDelta)) | |
| } | |
| } | |
| private struct ScrollWheelDeltaView: NSViewRepresentable { | |
| let onDelta: (CGFloat, CGFloat) -> Void | |
| func makeNSView(context: Context) -> NSView { | |
| let v = ScrollCatcherNSView() | |
| v.onDelta = onDelta | |
| return v | |
| } | |
| func updateNSView(_ nsView: NSView, context: Context) { | |
| (nsView as? ScrollCatcherNSView)?.onDelta = onDelta | |
| } | |
| private final class ScrollCatcherNSView: NSView { | |
| var onDelta: ((CGFloat, CGFloat) -> Void)? | |
| private var eventMonitor: Any? | |
| override func viewWillDraw() { | |
| super.viewWillDraw() | |
| } | |
| override func viewDidMoveToWindow() { | |
| super.viewDidMoveToWindow() | |
| if let eventMonitor { | |
| NSEvent.removeMonitor(eventMonitor) | |
| } | |
| eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in | |
| // if self?.hitTest(event.locationInWindow) == nil { | |
| // return event | |
| // } | |
| self?.onDelta?(event.scrollingDeltaX, event.scrollingDeltaY) | |
| return nil | |
| } | |
| } | |
| deinit { | |
| if let eventMonitor { | |
| NSEvent.removeMonitor(eventMonitor) | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment