-
-
Save twocentstudios/6deb870942ce0b69816c7550c73a3a14 to your computer and use it in GitHub Desktop.
Example code for "Path Drawing in SwiftUI"
This file contains 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
import SwiftUI | |
/// Example code for "Path Drawing in SwiftUI" | |
/// | |
/// Support: Xcode 14.2, iOS 16.0+ | |
/// | |
/// Instructions: | |
/// * Create a new SwiftUI app in Xcode. | |
/// * Paste these contents into a new file. | |
/// * Open SwiftUI Previews (opt+cmd+return) | |
struct RoundedRectShape: Shape { | |
var cornerRadius: CGFloat = 20 | |
func path(in rect: CGRect) -> Path { | |
Path(roundedRect: rect, cornerRadius: cornerRadius) | |
} | |
} | |
struct RoundedRectView_Previews: PreviewProvider { | |
static var previews: some View { | |
RoundedRectShape() | |
.fill(.gray) | |
.previewingShape() | |
} | |
} | |
struct RoundedRectStrokeView_Previews: PreviewProvider { | |
static var previews: some View { | |
RoundedRectShape() | |
.stroke(.gray) | |
.previewingShape() | |
} | |
} | |
struct RoundedRectStrokeFillView_Previews: PreviewProvider { | |
static var previews: some View { | |
ZStack { | |
RoundedRectShape() | |
.fill(.gray) | |
RoundedRectShape() | |
.stroke(Color.black, lineWidth: 4) | |
} | |
.previewingShape() | |
} | |
} | |
struct BannerShape: Shape { | |
func path(in rect: CGRect) -> Path { | |
return Path { p in | |
p.move(to: .init(x: rect.minX, y: rect.maxY)) | |
p.addLine(to: .init(x: rect.minX, y: rect.minY)) | |
p.addLine(to: .init(x: rect.maxX, y: rect.midY)) | |
p.closeSubpath() // same as: `p.addLine(to: .init(x: rect.minX, y: rect.maxY))` | |
} | |
} | |
} | |
struct BannerView_Previews: PreviewProvider { | |
static var previews: some View { | |
BannerShape() | |
.fill(.gray) | |
.previewingShape() | |
} | |
} | |
struct BannerAbsoluteShape: Shape { | |
func path(in rect: CGRect) -> Path { | |
return Path { p in | |
p.move(to: .init(x: 10, y: 50)) | |
p.addLine(to: .init(x: 10, y: 10)) | |
p.addLine(to: .init(x: 100, y: 30)) | |
p.closeSubpath() // same as: `p.addLine(to: .init(x: rect.minX, y: rect.maxY))` | |
} | |
} | |
} | |
struct BannerAbsoluteView_Previews: PreviewProvider { | |
static var previews: some View { | |
BannerAbsoluteShape() | |
.fill(Color(.darkGray)) | |
.background(Color(.lightGray)) | |
.previewingShape() | |
} | |
} | |
struct RoundedRectArcUnsafeShape: Shape { | |
let cornerRadius: CGFloat | |
func path(in rect: CGRect) -> Path { | |
Path { p in | |
p.move(to: .init(x: rect.minX + cornerRadius, y: rect.minY)) | |
p.addLine(to: .init(x: rect.maxX - cornerRadius, y: rect.minY)) | |
p.addArc( | |
tangent1End: .init(x: rect.maxX, y: rect.minY), | |
tangent2End: .init(x: rect.maxX, y: rect.minY + cornerRadius), | |
radius: cornerRadius | |
) | |
p.addLine(to: .init(x: rect.maxX, y: rect.maxY - cornerRadius)) | |
p.addArc( | |
tangent1End: .init(x: rect.maxX, y: rect.maxY), | |
tangent2End: .init(x: rect.maxX - cornerRadius, y: rect.maxY), | |
radius: cornerRadius | |
) | |
p.addLine(to: .init(x: rect.minX + cornerRadius, y: rect.maxY)) | |
p.addArc( | |
tangent1End: .init(x: rect.minX, y: rect.maxY), | |
tangent2End: .init(x: rect.minX, y: rect.maxY - cornerRadius), | |
radius: cornerRadius | |
) | |
p.addLine(to: .init(x: rect.minX, y: rect.minY + cornerRadius)) | |
p.addArc( | |
tangent1End: .init(x: rect.minX, y: rect.minY), | |
tangent2End: .init(x: rect.minX + cornerRadius, y: rect.minY), | |
radius: cornerRadius | |
) | |
p.closeSubpath() | |
} | |
} | |
} | |
struct RoundedRectArcUnsafeView_Previews: PreviewProvider { | |
static var previews: some View { | |
let cornerRadius: CGFloat = 100 | |
ZStack { | |
RoundedRectArcUnsafeShape(cornerRadius: cornerRadius) | |
.stroke(.gray, lineWidth: 9) | |
RoundedRectangle(cornerRadius: cornerRadius, style: .circular) | |
.stroke(.red, lineWidth: 1) | |
} | |
.previewingShape() | |
} | |
} | |
struct RoundedRectArcShape: Shape { | |
let cornerRadius: CGFloat | |
func path(in rect: CGRect) -> Path { | |
let maxBoundedCornerRadius = min(min(cornerRadius, rect.width / 2.0), rect.height / 2.0) | |
let minBoundedCornerRadius = max(maxBoundedCornerRadius, 0.0) | |
let boundedCornerRadius = minBoundedCornerRadius | |
return Path { p in | |
p.move(to: .init(x: rect.minX + boundedCornerRadius, y: rect.minY)) | |
p.addLine(to: .init(x: rect.maxX - boundedCornerRadius, y: rect.minY)) | |
p.addArc( | |
tangent1End: .init(x: rect.maxX, y: rect.minY), | |
tangent2End: .init(x: rect.maxX, y: rect.minY + boundedCornerRadius), | |
radius: boundedCornerRadius | |
) | |
p.addLine(to: .init(x: rect.maxX, y: rect.maxY - boundedCornerRadius)) | |
p.addArc( | |
tangent1End: .init(x: rect.maxX, y: rect.maxY), | |
tangent2End: .init(x: rect.maxX - boundedCornerRadius, y: rect.maxY), | |
radius: boundedCornerRadius | |
) | |
p.addLine(to: .init(x: rect.minX + boundedCornerRadius, y: rect.maxY)) | |
p.addArc( | |
tangent1End: .init(x: rect.minX, y: rect.maxY), | |
tangent2End: .init(x: rect.minX, y: rect.maxY - boundedCornerRadius), | |
radius: boundedCornerRadius | |
) | |
p.addLine(to: .init(x: rect.minX, y: rect.minY + boundedCornerRadius)) | |
p.addArc( | |
tangent1End: .init(x: rect.minX, y: rect.minY), | |
tangent2End: .init(x: rect.minX + boundedCornerRadius, y: rect.minY), | |
radius: boundedCornerRadius | |
) | |
p.closeSubpath() | |
} | |
} | |
} | |
struct RoundedRectArcView_Previews: PreviewProvider { | |
static var previews: some View { | |
let cornerRadius: CGFloat = 100 | |
ZStack { | |
RoundedRectArcShape(cornerRadius: cornerRadius) | |
.stroke(.gray, lineWidth: 9) | |
RoundedRectangle(cornerRadius: cornerRadius, style: .circular) | |
.stroke(.red, lineWidth: 1) | |
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) | |
.stroke(.blue, lineWidth: 1) | |
} | |
.previewingShape() | |
} | |
} | |
struct QuadCurveScoop: Shape { | |
/// 0...1 | |
var pointOffsetFraction: CGFloat = 0.0 | |
func path(in rect: CGRect) -> Path { | |
Path { p in | |
p.move(to: .init(x: rect.minX, y: rect.minY)) | |
p.addQuadCurve( | |
to: .init(x: rect.maxX, y: rect.minY), | |
control: .init(x: rect.maxX * pointOffsetFraction, y: rect.maxY) | |
) | |
} | |
} | |
} | |
struct QuadCurveScoopView_Previews: PreviewProvider { | |
static var previews: some View { | |
HStack(alignment: .top) { | |
QuadCurveScoop(pointOffsetFraction: 0.0) | |
.fill(Color(.darkGray)) | |
.frame(height: 100) | |
.border(Color(.red)) | |
.overlay(alignment: .bottomLeading) { | |
Circle() | |
.fill(Color(.darkGray)) | |
.frame(width: 4, height: 4) | |
} | |
QuadCurveScoop(pointOffsetFraction: 0.5) | |
.fill(Color(.darkGray)) | |
.frame(height: 100) | |
.border(Color(.red)) | |
.overlay(alignment: .bottom) { | |
Circle() | |
.fill(Color(.darkGray)) | |
.frame(width: 4, height: 4) | |
} | |
QuadCurveScoop(pointOffsetFraction: 1.0) | |
.fill(Color(.darkGray)) | |
.border(Color(.red)) | |
.overlay(alignment: .bottomTrailing) { | |
Circle() | |
.fill(Color(.darkGray)) | |
.frame(width: 4, height: 4) | |
} | |
} | |
.background(Color(.lightGray)) | |
.frame(width: 200, height: 200) | |
.padding(50) | |
.previewLayout(.sizeThatFits) | |
} | |
} | |
struct Cloud1Shape: Shape { | |
func path(in rect: CGRect) -> Path { | |
let inset = rect.width / 2.0 | |
return Path { p in | |
p.addEllipse(in: rect.inset(by: .init(top: 0, left: 0, bottom: 0, right: inset))) | |
p.addEllipse(in: rect.inset(by: .init(top: 0, left: inset / 2.0, bottom: 0, right: inset / 2.0))) | |
p.addEllipse(in: rect.inset(by: .init(top: 0, left: inset, bottom: 0, right: 0))) | |
} | |
} | |
} | |
struct Cloud1View_Previews: PreviewProvider { | |
static var previews: some View { | |
Cloud1Shape() | |
.fill(.gray) | |
.previewingShape() | |
} | |
} | |
struct Cloud1StrokeView_Previews: PreviewProvider { | |
static var previews: some View { | |
Cloud1Shape() | |
.stroke(.gray, lineWidth: 4) | |
.previewingShape() | |
} | |
} | |
struct Cloud2Shape: Shape { | |
func path(in rect: CGRect) -> Path { | |
let inset = rect.width / 2.0 | |
let leftEllipse = Path(ellipseIn: rect.inset(by: .init(top: 0, left: 0, bottom: 0, right: inset))) | |
let centerEllipse = Path(ellipseIn: rect.inset(by: .init(top: 0, left: inset / 2.0, bottom: 0, right: inset / 2.0))) | |
let rightEllipse = Path(ellipseIn: rect.inset(by: .init(top: 0, left: inset, bottom: 0, right: 0))) | |
let combinedCGPath = leftEllipse.cgPath | |
.union(centerEllipse.cgPath) | |
.union(rightEllipse.cgPath) | |
return Path(combinedCGPath) | |
} | |
} | |
struct Cloud2StrokeView_Previews: PreviewProvider { | |
static var previews: some View { | |
Cloud2Shape() | |
.stroke(.gray, lineWidth: 4) | |
.previewingShape() | |
} | |
} | |
struct MapOnboardingBubbleShape: Shape { | |
var cornerRadius: CGFloat = 12 | |
var arrowRectSize: CGFloat = 20 | |
var arcLength: CGFloat = 12 | |
/// 0.0 = left, 0.5 = center, 1.0 = right | |
var arrowOffsetFraction: CGFloat = 0.5 | |
func baseXPos(for rect: CGRect) -> CGFloat { | |
(rect.maxX - cornerRadius - cornerRadius - arrowRectSize) * arrowOffsetFraction + cornerRadius | |
} | |
func path(in rect: CGRect) -> Path { | |
let roundedRect = Path(roundedRect: rect, cornerRadius: cornerRadius) | |
let arrowPath = Path { p in | |
p.move(to: .init(x: baseXPos(for: rect), y: rect.maxY)) | |
p.addLine(to: .init( | |
x: baseXPos(for: rect) + arrowRectSize - arcLength, | |
y: rect.maxY + arrowRectSize - arcLength | |
)) | |
p.addQuadCurve( | |
to: .init( | |
x: baseXPos(for: rect) + arrowRectSize, | |
y: rect.maxY + arrowRectSize - arcLength | |
), | |
control: .init( | |
x: baseXPos(for: rect) + arrowRectSize, | |
y: rect.maxY + arrowRectSize | |
) | |
) | |
p.addLine(to: .init(x: baseXPos(for: rect) + arrowRectSize, y: rect.maxY)) | |
p.closeSubpath() | |
} | |
let combinedCGPath = roundedRect.cgPath.union(arrowPath.cgPath) | |
let combinedPath = Path(combinedCGPath) | |
return combinedPath | |
} | |
} | |
struct MapOnboardingBubbleShape_Previews: PreviewProvider { | |
static var previews: some View { | |
VStack(spacing: 30) { | |
MapOnboardingBubbleShape(arrowOffsetFraction: 0.0).fill(Color(.darkGray)) | |
.frame(width: 150, height: 100, alignment: .center) | |
.overlay { Text("0.0").foregroundColor(.white) } | |
MapOnboardingBubbleShape(arrowOffsetFraction: 0.5).fill(Color(.darkGray)) | |
.frame(width: 150, height: 100, alignment: .center) | |
.overlay { Text("0.5").foregroundColor(.white) } | |
MapOnboardingBubbleShape(arrowOffsetFraction: 1.0).fill(Color(.darkGray)) | |
.frame(width: 150, height: 100, alignment: .center) | |
.overlay { Text("1.0").foregroundColor(.white) } | |
} | |
.padding(50) | |
.previewLayout(.sizeThatFits) | |
} | |
} | |
struct DrawBubbleView: View { | |
@State var drawFraction: CGFloat = 0 | |
var body: some View { | |
VStack { | |
MapOnboardingBubbleShape() | |
.trim(from: 0, to: drawFraction) | |
.stroke(.gray, lineWidth: 3) | |
.animation(.spring(), value: drawFraction) | |
.frame(width: 150, height: 100) | |
.padding(.bottom, 50) | |
Button(drawFraction > 0.0 ? "Hide" : "Show") { | |
drawFraction = drawFraction > 0.0 ? 0.0 : 1.0 | |
} | |
.tint(Color.gray) | |
} | |
} | |
} | |
struct DrawBubbleView_Previews: PreviewProvider { | |
static var previews: some View { | |
DrawBubbleView() | |
} | |
} | |
struct BubbleTransitionView: View { | |
@State var isVisible: Bool = false | |
var body: some View { | |
VStack { | |
ZStack { | |
if isVisible { | |
Text("Hello!") | |
.padding(30) | |
.background { | |
MapOnboardingBubbleShape().fill(Color(.systemGray5)) | |
} | |
.transition(.opacity.combined(with: .scale).animation(.spring(response: 0.25, dampingFraction: 0.7))) | |
} | |
} | |
.frame(width: 200, height: 100) | |
.padding(.bottom, 50) | |
Button(isVisible ? "Hide" : "Show") { | |
isVisible.toggle() | |
} | |
.tint(Color.gray) | |
} | |
} | |
} | |
struct BubbleTransitionView_Previews: PreviewProvider { | |
static var previews: some View { | |
BubbleTransitionView() | |
} | |
} | |
extension View { | |
@ViewBuilder func previewingShape() -> some View { | |
frame(width: 200, height: 100) | |
.padding(50) | |
.previewLayout(.sizeThatFits) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment