Skip to content

Instantly share code, notes, and snippets.

@stephancasas
Created May 18, 2026 21:44
Show Gist options
  • Select an option

  • Save stephancasas/9e70dca84949fad9fe7909505d26b125 to your computer and use it in GitHub Desktop.

Select an option

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
//
// 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