Search code examples
iosswiftswiftuidynamic-island

How do I draw a rounded corner triangle with Path in SwiftUI?


im trying to replicate a view I saw on Apple's documentation about the Dynamic Island and live activities, I managed to make the rounded arc (with some help haha) and now im trying to do the rounded pizza-like shape. Also im not sure how to add the little rotated satellite to the view

This is the view im trying to replicate:

enter image description here

I tried and managed to get the pizza-like shape with Path but I don't know how to round the corners, I tried .cornerRadius , .clipShape and didn't work.

This is my current code:

Arc:

This was done with some help on my previous question

struct Arc: Shape {
    /// Percent
    ///
    /// How much of the Arc should we draw? `1` means 100%. `0.5` means half. Etc.
    let percent: CGFloat

    /// Line width
    ///
    /// How wide is the line going to be stroked. This is used to offset the arc within the `CGRect`.
    let lineWidth: CGFloat

    init(percent: CGFloat = 1, lineWidth: CGFloat = 1) {
        self.percent = percent
        self.lineWidth = lineWidth
    }

    func path(in rect: CGRect) -> Path {
        let rect = rect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)
        var path = Path()

        let x = rect.width / 2
        let y = rect.height
        let w = sqrt(x * x + y * y)
        let phi = atan2(x, y)

        let radius = w / 2 / cos(phi)
        let center = CGPoint(x: rect.minX + x, y: rect.minY + radius)

        let theta = 2 * (.pi / 2 - phi) * percent

        let startAngle = Angle(radians: 3 * .pi / 2 - theta)
        let endAngle = Angle(radians: 3 * .pi / 2 + theta)
        path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)

        return path
    }
}

RoundedTriangleShape:

struct RoundedTriangleShape : Shape {
  
  func path(in rect: CGRect) -> Path {
    
    var path = Path()
 
    let x = rect.width / 2
    let y = rect.height
    let w = sqrt(x * x + y * y)
    let phi = atan2(x, y)

    let radius = w / 2 / cos(phi)
    let center = CGPoint(x: rect.minX + x, y: rect.minY + radius)

    let startAngle: Angle = .degrees(250)
    let endAngle: Angle = .degrees(290)
    
    path.move(to: CGPoint(x: rect.midX, y: rect.midY + y / 1.5))
    
    path.addArc(center: center, radius: radius - 10, startAngle: startAngle, endAngle: endAngle, clockwise: false)

    path.closeSubpath()
    
    return path
  }
}

DynamicIslandExpandedRegion:

DynamicIslandExpandedRegion(.bottom) {
  GeometryReader { geometry in
    HStack {
      Spacer()
      ZStack {
        Arc(lineWidth: 20)
          .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
          .foregroundStyle(.indigo.opacity(0.5))

        RoundedTriangleShape()
          .foregroundStyle(.linearGradient(colors: [.blue, .cyan],
                                           startPoint: .bottom,
                                           endPoint: .top).opacity(0.5))

        Arc(percent: 0.35, lineWidth: 20)
          .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
          .foregroundStyle(.cyan)

      }
      .frame(width: geometry.size.width / 1.2,
             height: geometry.size.width / 5, alignment: .center)
      Spacer()
    }
  }
  .frame(height: 300)
}

And the current state of the view:

Current State:

enter image description here


Solution

  • The usual trick for rounded corners would be to stroke the path with the wide lineWidth, and then to fill the same path. But that will not work here because you are using an opacity of 0.5, so where the stroked outline and the fill overlapped, you would get greater opacity than you intended.

    So, here, you need to construct a path of the outline of the shape you want. And this will consist of the top outer edge of the main arc (in red, below), an arc for the top-right corner (yellow), an arc for the bottom (black), and a final arc for the top-left corner (white).

    stroking the four path edges

    Note, we do not need to stroke the left and right sides, as those will be rendered for us: When you have an addPath for one arc followed by another, it will automatically include a line from the end of the previous path to the start of the next one. The trick is to have the arcs start and end at the same angle that matches the line between them:

    enter image description here

    Anyway, that is achieved with something like the following, expanding upon my prior answer:

    struct ArcFilledOutline: Shape {
        /// Percent
        ///
        /// How much of the Arc should we draw? `1` means 100%. `0.5` means half. Etc.
        let percent: CGFloat
    
        /// Line width
        ///
        /// How wide is the line going to be stroked. This is used to offset the arc within the `CGRect`.
        let lineWidth: CGFloat
    
        init(percent: CGFloat = 1, lineWidth: CGFloat = 1) {
            self.percent = percent
            self.lineWidth = lineWidth
        }
    
        func path(in rect: CGRect) -> Path {
            let rect = rect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)
            var path = Path()
    
            let x = rect.width / 2
            let y = rect.height
            let w = sqrt(x * x + y * y)
            let phi = atan2(x, y)
    
            let radius = w / 2 / cos(phi)
            let center = CGPoint(x: rect.minX + x, y: rect.minY + radius)
    
            let theta = 2 * (.pi / 2 - phi) * percent
    
            let startAngle = Angle(radians: 3 * .pi / 2 - theta)
            let endAngle = Angle(radians: 3 * .pi / 2 + theta)
            path.addArc(
                center: center,
                radius: radius + lineWidth / 2,
                startAngle: startAngle,
                endAngle: endAngle,
                clockwise: false
            )
    
            let topRightCenter = CGPoint(x: center.x + radius * CGFloat(cos(endAngle.radians)), y: center.y + radius * CGFloat(sin(endAngle.radians)))
            let topLeftCenter = CGPoint(x: center.x + radius * CGFloat(cos(startAngle.radians)), y: center.y + radius * CGFloat(sin(startAngle.radians)))
            let bottomCenter = CGPoint(x: rect.midX, y: rect.maxY)
            let rightAngle = Angle(radians: atan2(topRightCenter.y - bottomCenter.y, topRightCenter.x - bottomCenter.x) + .pi / 2)
            let leftAngle = Angle(radians: atan2(bottomCenter.y - topLeftCenter.y, bottomCenter.x - topLeftCenter.x) + .pi / 2)
    
            path.addArc(
                center: topRightCenter,
                radius: lineWidth / 2,
                startAngle: endAngle,
                endAngle: rightAngle,
                clockwise: false
            )
    
            path.addArc(
                center: bottomCenter,
                radius: lineWidth / 2,
                startAngle: rightAngle,
                endAngle: leftAngle,
                clockwise: false
            )
    
            path.addArc(
                center: topLeftCenter,
                radius: lineWidth / 2,
                startAngle: leftAngle,
                endAngle: startAngle,
                clockwise: false
            )
    
            path.closeSubpath()
    
            return path
        }
    }
    
    struct Arc: Shape {
        /// Percent
        ///
        /// How much of the Arc should we draw? `1` means 100%. `0.5` means half. Etc.
        let percent: CGFloat
    
        /// Line width
        ///
        /// How wide is the line going to be stroked. This is used to offset the arc within the `CGRect`.
        let lineWidth: CGFloat
    
        init(percent: CGFloat = 1, lineWidth: CGFloat = 1) {
            self.percent = percent
            self.lineWidth = lineWidth
        }
    
        func path(in rect: CGRect) -> Path {
            let rect = rect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)
            var path = Path()
    
            let x = rect.width / 2
            let y = rect.height
            let w = sqrt(x * x + y * y)
            let phi = atan2(x, y)
    
            let radius = w / 2 / cos(phi)
            let center = CGPoint(x: rect.minX + x, y: rect.minY + radius)
    
            let theta = 2 * (.pi / 2 - phi) * percent
    
            let startAngle = Angle(radians: 3 * .pi / 2 - theta)
            let endAngle = Angle(radians: 3 * .pi / 2 + theta)
            path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
    
            return path
        }
    }
    

    And needless to say, rather than stroking the outline path, like I did above, you would just fill as desired. E.g.:

    struct ContentView: View {
        var body: some View {
            GeometryReader { geometry in
                ZStack {
                    Arc(lineWidth: 20)
                        .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
                        .foregroundStyle(Color.blue)
                        .frame(width: geometry.size.width, height: geometry.size.width / 4, alignment: .center)
    
                    ArcFilledOutline(percent: 0.33, lineWidth: 20)
                        .fill()
                        .foregroundStyle(
                            .linearGradient(
                                colors: [.blue, .cyan],
                                startPoint: .bottom,
                                endPoint: .top
                            ).opacity(0.5)
                        )
                        .frame(width: geometry.size.width, height: geometry.size.width / 4, alignment: .center)
    
                    Arc(percent: 0.33, lineWidth: 20)
                        .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
                        .foregroundStyle(Color.cyan)
                        .frame(width: geometry.size.width, height: geometry.size.width / 4, alignment: .center)
                }
            }
            .padding()
        }
    }
    

    Yielding:

    final result