Path Drawing in SwiftUI

Hi, this is Chris Trott (@twocentstudios) from Cookpad Mart's iOS team.

In this post I want to share a few tips for how to draw shapes using paths in SwiftUI, starting from the basics. The code in this post targets iOS 16 and Xcode 14, but is low-level enough that it should be relatively forward and backward compatible.

Drawing paths manually is not a common task in day-to-day app work. It can be especially tedious for complex shapes. However, it can sometimes be a most prudent choice over bitmaps or 3rd party rendering libraries.

You can view the complete code from this post from this gist.

Contents

  • Basic shapes
  • Styling
  • Drawing line-by-line
  • How to use arcs
  • How to use quadratic bezier curves
  • Path operations
  • Creating a chat bubble shape
  • Trimming a path
  • Transition animations

Basic shapes

SwiftUI has a protocol Shape – both conforming to, and conceptually similar to View – that we can use to draw custom shapes.

protocol Shape : Animatable, View

It has one requirement: a function that takes a CGRect and returns a Path.

import SwiftUI

struct MyCustomShape: Shape {
    func path(in rect: CGRect) -> Path {
        /// TODO: return a `Path`
    }
}

As we'll see later, the input rect is determined by SwiftUI's layout system rules, but the path we return can draw anywhere, including outside the bounds of rect.

Path is SwiftUI's drawing command primitive, while UIKit has UIBezierPath and CoreGraphics has CGPath. All are similar, but not quite the same.

Let's use SwiftUI's Path primitives to make a simple rounded rectangle.

struct RoundedRectShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path(roundedRect: rect, cornerRadius: 20)
    }
}

Of course this is the same as SwiftUI's built-in shape:

RoundedRectangle(cornerRadius: 20)

A Shape has only a "default fill based on the foreground color", so let's add a SwiftUI Preview for it.

struct RoundedRectView_Previews: PreviewProvider {
    static var previews: some View {
        RoundedRectShape()
            .fill(.gray)
            .frame(width: 200, height: 150)
            .padding(50)
            .previewLayout(.sizeThatFits)
    }
}

Why not make a custom view modifier for making previewing more convenient:

extension View {
    @ViewBuilder func previewingShape() -> some View {
        frame(width: 200, height: 150)
            .padding(50)
            .previewLayout(.sizeThatFits)
    }
}

struct RoundedRectView_Previews: PreviewProvider {
    static var previews: some View {
        RoundedRectShape()
            .fill(.gray)
            .previewingShape()
    }
}

Styling

Before we get too far into the weeds with Path, we should take a look at basic Shape styling. Otherwise, how will we be able to see what we're drawing?

We can either stroke or fill a shape instance, but not both. This is because .stroke and .fill are both defined on Shape but return a View.

RoundedRectShape()
    .fill(.gray)
RoundedRectShape()
    .stroke(.gray)
RoundedRectShape()
    .stroke(.gray)
    .fill(.gray) // Error: Value of type 'some View' has no member 'fill'
Fill Stroke

To do both, we need to layer two separate instances of the shape:

ZStack {
    RoundedRectShape()
        .fill(.gray)

    RoundedRectShape()
        .stroke(Color.black, lineWidth: 4)
}

Drawing line-by-line

We draw a path line-by-line or curve-by-curve as if we were describing pen strokes to a friend.

  • move(to:) moves the "cursor" without drawing.
  • addLine(to:) draws a line from current "cursor" to the the to point.
  • closeSubpath() marks the subpath as closed by drawing a line from the "cursor" back to the start point if necessary.

Note: it's required to call move(to:) before adding a line or curve. Otherwise the path will not appear. When adding a complete subpath like addEllipse(in:), move(to:) is not required.

Let's draw a banner shape, starting from the bottom left corner:

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()
        }
    }
}

Since we're using the rect parameter to specify our drawing points, the shape will always be relative to the size of the view.

We could also specify absolute coordinates:

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()
        }
    }
}

And you can see from the lighter gray background color I've added to the view that the path that defines the shape no longer fills it.

How to use arcs

There are three APIs for drawing an arc:

/// Adds an arc of a circle to the path, specified with a radius and a
/// difference in angle.
public mutating func addRelativeArc(center: CGPoint, radius: CGFloat, startAngle: Angle, delta: Angle, transform: CGAffineTransform = .identity)

/// Adds an arc of a circle to the path, specified with a radius and angles.
public mutating func addArc(center: CGPoint, radius: CGFloat, startAngle: Angle, endAngle: Angle, clockwise: Bool, transform: CGAffineTransform = .identity)

/// Adds an arc of a circle to the path, specified with a radius and two
/// tangent lines.
public mutating func addArc(tangent1End p1: CGPoint, tangent2End p2: CGPoint, radius: CGFloat, transform: CGAffineTransform = .identity)

The first two add a new subpath disconnected from the current path.

The last one – using tangents – adds an arc connected to the current subpath. We can use this API to add an arc to a line-by-line drawing session like the banner above.

Let's create the rounded rectangle shape with only the addLine and addArc primitives. It should take a corner radius as a parameter and draw inside the provided bounds rectangle.

First, we'll visualize what we want to draw. The black-outlined rectangle is a representation of our input rectangle and the gray-filled shape is the target shape we want to draw.

The corner radius r can be visualized as a square, situated at each corner of the bounds rectangle, with side r.

Looking at the addArc) function again:

func addArc(tangent1End p1: CGPoint, tangent2End p2: CGPoint, radius: CGFloat)

We need to assemble 4 parameters:

  1. startPoint (implicit; this is where the "cursor" is)
  2. tangent1End
  3. tangent2End
  4. radius

We only know (4) radius.

Despite the potentially 🤔 names, the tangents correspond to the following points on the aforementioned square:

Zooming out to the whole rectangle, if we decide to draw clockwise, that means we'll have 4 arcs with the following points:

Let's alternate drawing lines and arcs, clockwise, and in the following order:

We want the points to be drawn relative to the bounds rectangle. We can use the following helper functions on CGRect to derive the corner points we need:

  • CGRect.minX
  • CGRect.maxX
  • CGRect.minY
  • CGRect.maxY
  • CGRect.midX (also useful)
  • CGRect.midY (also useful)

If you mix up these helpers while writing drawing code, you're in good company.

We derive the non-corner points by adding or subtracting the corner radius.

With all the details worked out, all we have to do is arrange the code:

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()
        }
    }
}

If we overlay SwiftUI's build-in RoundedRectangle shape, ours looks pretty good:

struct RoundedRectArcUnsafeView_Previews: PreviewProvider {
    static var previews: some View {
        let cornerRadius: CGFloat = 20
        ZStack {
            RoundedRectArcUnsafeShape(cornerRadius: cornerRadius)
                .stroke(.gray, lineWidth: 9)
            RoundedRectangle(cornerRadius: cornerRadius, style: .circular)
                .stroke(.red, lineWidth: 1)
        }
        .previewingShape()
    }
}

But what happens if we make cornerRadius something like 100 (when our shape height is 100)?

Looks like SwiftUI's version does some bounds checking so the shape becomes a capsule or circle. Let's fix our implementation:

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))
            // ...
        }
    }
}

That's better. As a bonus, I'm showing SwiftUI's .continuous corner style in blue over the .circular style in red.

How to use quadratic bezier curves

We often want to avoid the kinds of sharp corners that appear when connecting lines, but don't necessarily want to use circular arcs.

For smoother lines, the Path API gives us:

  • addCurve for cubic Bézier curves
  • addQuadCurve for quadratic Bézier curves

Cubic Bézier curves give us a lot of flexibility. They can also be a weighty topic. I recommend this YouTube video by Freya Holmér.

I've found quadratic Bézier curves as a nice compromise between flexibility and complexity, so let's try to quickly build some intuition on how to use them.

Let's start by looking at the addQuadCurve) function:

func addQuadCurve(to p: CGPoint, control cp: CGPoint)

We need to assemble 3 parameters:

  1. startPoint (implicit; this is where the "cursor" is)
  2. endPoint (p)
  3. controlPoint (cp)

When we set up the three points as various triangles, we can see that the curve is stretched towards the control point.

Calculating the actual positions of the three points will depend on our use case.

Let's say we want to draw a simple quad curve as a "scoop" with the control point at the bottom. But we'll allow the caller to specify a relative position on the x-axis for the control point.

Add the input rectangle to our planning diagram will help us determine how to calculate each of the three points:

With that, here's the code:

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)
            )
        }
    }
}

If we don't explicitly close the subpath, SwiftUI presumably closes it for us when drawing.

I've set up the preview to mimic the figure above, and I've added an overlay to show the input rectangle and approximate control point for each curve.

Path operations

Path operations look like set operations: union, intersection, subtracting, etc.

These operations allow us to combine subpaths in unique ways, without necessarily needing to draw line-by-line or arc-by-arc.

Let's try making a cloud shape by adding together 3 ellipses:

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)))
        }
    }
}

When we fill it, it looks fine:

struct Cloud1View_Previews: PreviewProvider {
    static var previews: some View {
        Cloud1Shape()
            .fill(.gray)
            .previewingShape()
    }
}

But if we decide to draw an outline instead, it looks like 3 ellipses:

We can fix this by joining the shapes together using the union path operation.

The path operation APIs are available on iOS 16+. Unfortunately, they're defined on CGPath and not Path. It's simple to convert between them, but we'll have to rewrite our path drawing code.

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)
    }
}

Now when we outline the shape, we get a cloud again.

Creating a chat bubble shape

I used the above techniques to create a chat bubble shape for the onboarding section of the recently decommissioned Tabedori たべドリ app.

The arrow position on the bottom can be adjusted by providing arrowOffsetFraction.

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

The arrowOffsetFraction is the text inside the bubble.

Here's a screenshot of it in context:

Trimming a path

Animating the path is something that can't be done (easily) with a single static image, but is easy to do with a Shape.

The trim modifier on Shape allows you to draw only a variable fraction of the path.

Since SwiftUI is adept at many kinds of animations, we can use it to animate the path being drawn:

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)
        }
    }
}

Transition animations

And finally, since Shapes have appearance/disappearance transitions like any other View, we can add a fun springy insertion animation.

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()
            }
        }
    }
}

Conclusion

Thanks for reading! I hope this post has led you on a path of enlightenment.

RubyKaigi 2023 Wi-Fi: 足回り徹底解説

id:sora_h です。最近は RubyKaigi の Organizer や Wi-Fi NOC をやっていましたが… 何屋なんだろう? 一応 Software Engineer (Site Reliability, Corporate Engineering) を名乗っていますが…。あっ RubyKaigi から戻ってからは学者をやってますね。落ち着いたら本業を思い出していこうと思います。

さて、Cookpad は 2010 年より RubyKaigi に協賛していますが、近年は Wi-Fi Sponsor など*1として携わっています。実体的には、 id:sora_h (筆者) が RubyKaigi 前にほぼフルタイムで Wi-Fi の準備に提供されたり、細々とした機材、一部の回線・ラックスペースの提供を行っています *2

本稿では RubyKaigi 2023 Wi-Fi ネットワークの L1~L4 設計について解説します *3。Wi-Fi についてというより、Wi-Fi AP より先の足回り、会場のルータからインターネットまでの区間についてがメイントピックです。

*1:Ruby Committers’ スポンサー (2017-2022) や Rubyists on Rails スポンサー (2023) も平行してやっています。Wi-Fi スポンサーは 2017 年から

*2:RubyKaigi の Wi-Fi 機材の大半はスポンサーのみなさまからの協賛金をもとにして RubyKaigi で購入所有しています。スポンサーのみなさま、ありがとうございます!

*3:RubyKaigi 2022 でもほぼ同様の構成を取っていました

続きを読む

RubyKaigi 2023の冷蔵庫は何だったのか

エンジニアの成田(@mirakui)です。最近はクックパッドマートの流通基盤エンジニアとして、商品の流通に関わるソフトウェアやハードウェアに携わっています。

さて、クックパッドは先日長野県の松本で開催された RubyKaigi 2023 にスポンサーとして参加しました。そのスポンサーシップの一環として、参加者に配られるドリンクを冷やすための冷蔵庫を提供しました。

会場に設置した6台の冷蔵庫は、私たちが「マートステーション」と呼ぶ、クックパッドマートにおいてユーザーが購入した商品を受け取るための冷蔵庫です。現在は都内を中心に、駅やコンビニエンスストア、マンションの共用部といった生活動線に設置しています。マートステーションの技術的な詳細は下記の記事をご覧下さい。

techlife.cookpad.com

今回設置したのは、上記の記事中で "JCM-Mk4" と呼んでいる、現行型である第4世代の冷蔵庫です。それまでのモデル第3世代とはベースとなる冷蔵庫は同じですが、断熱性能を高めて結露対策を行ったり、Raspberry Pi を使うのを止めて、より安定性の高い産業用の機材に変更したりといった改良が加わっています。

冷蔵庫監視ダッシュボード

会場のクックパッドブースでは、6台の冷蔵庫をリアルタイムに監視する Grafana ダッシュボードを展示していました。

会場で展示した冷蔵庫のダッシュボード

ダッシュボードには、会場に設置したそれぞれの冷蔵庫において、主に以下のような値を表示していました。

  • 冷蔵庫の庫内温度
  • 解錠状態
  • ドアの開け閉めの状態

各冷蔵庫には産業用IoTゲートウェイであるFutureNet MA-S110 が取り付けられています。これは簡単に言うと小型 Linux PC であり、SIM カードやアンテナを取り付けることで、LTE 回線を通じてインターネットに繋がることができます。

ダッシュボードは Grafana で作られており、AWS 上にある Prometheus に格納されたデータを表示していました。Prometheus からは SORACOM Gate を経由して、各冷蔵庫の SORACOM Air SIM と通信を行い、温度センサーや鍵の会場状態の値を取得しています。これらの値を返しているのは、もちろん Ruby です!(RubyKaigi しぐさ)Linux 上で Sinatra の API サーバが動いていて、冷蔵庫における各種デバイスの制御や状態取得を司っています。

クックパッドマートは主に生鮮食品を配送するサービスであるため、商品の温度を担保することは事業において非常に重要です。たとえばもし冷蔵庫のドアが開けっぱなしになっていて庫内の温度が上がってしまうと、商品が傷んでしまう可能性があります。そのために私たちは冷蔵庫の温度やドアの開閉などを遠隔監視できるようにしており、会場で展示したダッシュボードは実際に運用で用いているものを簡易化したものです。

写真で振り返る RubyKaigi 冷蔵庫

RubyKaigi 開催前日です。会場に6台の冷蔵庫が届きました。各種の配線を行う私です。配線を終えて電源を入れればやがてオンラインになり、冷蔵庫の管理システムに自動的に登録されます。

設営中の筆者 / Photo by @pastak

設営を完了した6台の冷蔵庫です。中にはドリンクが入っています。

会場に設置した冷蔵庫

ドリンクはみやもとファームのりんごジュースと松本ブルワリーのクラフトビールで、RubyKaigi から提供されました。りんごジュースはりんごの品種違いで4種類あり、風味の違いを楽しむことができました。松本ブルワリーのビールは Session IPA と RubyKaigi 2023 Matsumoto Lager で、どちらも RubyKaigi 2023 のオリジナルラベルがデザインされていました。

提供されたドリンク

冷蔵庫を開けるためには QR コードリーダーに解錠用の QR コードをかざす必要があります。実際の商品受け取りではクックパッドマートのアプリ上に表示される購入者用の QR コードを使って解錠するのですが、RubyKaigi の解錠では簡易的に、あらかじめ印刷した QR コードをぶら下げておくことで誰でも解錠できるようにしました。ちなみにこのカードは私の手作りです。QR コードは現地でマートのラベルプリンターを使って印刷しました。

解錠用のQRコード

りんごジュースは全日提供でしたが、ビールの提供は17時以降のみにしていました。Ruby で制御しているスマートロックなので、17時以降でないと解錠できないというギミックも簡単に用意できるのですが、わかりやすさを優先して物理的に QR コードリーダーを封印していました。

物理的な封印。これもマートのラベルプリンターで現地作成

クックパッドブースではダッシュボードの展示を行っていました。ビールの提供が始まる17時以降、ドアの開け閉めによって一斉にグラフが動き始めます。

クックパッドブースのダッシュボード展示

私もエンジニアとして、期待通りビールが冷やされているか確認を行いました。

温度監視の動作確認

おわりに

RubyKaigi 2023 では、とてもおいしいりんごジュースとクラフトビールが用意されました。私たちは大量に冷蔵庫を所有するスポンサーとして偶然居合わせたので、喜んで冷蔵庫を提供しつつ、クックパッドマートの流通の裏側をデモする機会をいただきました。クックパッドマートでは、生鮮食品を適切な温度を保ちながらユーザーに届けるために様々な工夫を凝らしています。首都圏のサービス提供エリアにお住まいの方は、ぜひクックパッドマートで買物をしてみて、生産者から冷蔵庫に届くまでの技術に思いを馳せていただければ幸いです。 最後に、RubyKaigi 2023 スタッフのみなさまおよび、ブースに訪れていただいたみなさま、どうもありがとうございました。