John B
John B

Reputation: 99

Adding rounded corners to custom SwiftUI shape?

Trying to find out the best way to add a radius/slightly rounded corners to this diamond shape code. Have tried a few things yielding some pretty odd results. Any help is greatly appreaciated.

struct DiamondShape: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)

        path.move(to: CGPoint(x: center.x, y: 0))
        path.addLine(to: CGPoint(x: rect.width, y: center.y))
        path.addLine(to: CGPoint(x: center.x, y: rect.height))
        path.addLine(to: CGPoint(x: 0, y: center.y))
        path.addLine(to: CGPoint(x: center.x, y: 0))

        return path
    }
}

Upvotes: 1

Views: 255

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 21720

I don't think there is a simple SwiftUI modifier that makes it possible to round the corners of an arbitrary shape. Instead, it is necessary to add the curves to the path yourself.

Here are some ways to do this:

Adapt the custom shape manually

In the case of a diamond shape, it is not too difficult to add the curves. The following principles describe a simple approach:

  • The points of the diamond can be used as the control points for quad curves.
  • The corner radius can be interpreted as the length along the side from a point where a curve starts and ends.
  • Using a bit of pythagoras and trigonometry theory, the x-offset and y-offset for the curve points can be computed. The same x-offset and y-offset can be used for all four corners.

Here is an updated version of DiamondShape that works this way:

struct RoundedDiamond: Shape {
    var cornerRadius: CGFloat = 10

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let doubleSideLen = ((rect.width * rect.width) + (rect.height * rect.height)).squareRoot()
        let xOffset = cornerRadius * (rect.width / doubleSideLen)
        let yOffset = cornerRadius * (rect.height / doubleSideLen)
        var x = rect.midX - xOffset
        var y = rect.minY + yOffset
        path.move(to: CGPoint(x: x, y: y))
        x += (2 * xOffset)
        path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: rect.midX, y: rect.minY))
        x = rect.maxX - xOffset
        y = rect.midY - yOffset
        path.addLine(to: CGPoint(x: x, y: y))
        y += (2 * yOffset)
        path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: rect.maxX, y: rect.midY))
        x = rect.midX + xOffset
        y = rect.maxY - yOffset
        path.addLine(to: CGPoint(x: x, y: y))
        x -= (2 * xOffset)
        path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: rect.midX, y: rect.maxY))
        x = rect.minX + xOffset
        y = rect.midY + yOffset
        path.addLine(to: CGPoint(x: x, y: y))
        y -= (2 * yOffset)
        path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: rect.minX, y: rect.midY))
        path.closeSubpath()
        return path
    }
}

Some test cases:

HStack {
    RoundedDiamond()
        .frame(width: 60, height: 240)
        .border(.red)
    RoundedDiamond()
        .frame(width: 120, height: 240)
        .border(.red)
    RoundedDiamond()
        .frame(width: 180, height: 240)
        .border(.red)
}

Screenshot

A generic solution using the same approach

The approach above can be turned into a more generic solution that builds a path from a set of points, using each point as the control point for a quad curve:

struct SimpleRoundedShape: Shape {
    let points: [CGPoint]
    var cornerRadius: CGFloat = 10

    func path(in rect: CGRect) -> Path {
        var path = Path()
        var finalCurvePoint: CGPoint?
        var prevControlPoint: CGPoint? = points.last
        for point in points {
            if let prevControlPoint {
                let dx = point.x - prevControlPoint.x
                let dy = point.y - prevControlPoint.y
                let len = ((dx * dx) + (dy * dy)).squareRoot()
                if len > 0 {
                    let curveOffset = min(cornerRadius, len / 2)
                    let xOffset = curveOffset * (dx / len)
                    let yOffset = curveOffset * (dy / len)
                    let prevCurveEnd = CGPoint(
                        x: prevControlPoint.x + xOffset,
                        y: prevControlPoint.y + yOffset
                    )
                    let nextCurveBegin = CGPoint(
                        x: point.x - xOffset,
                        y: point.y - yOffset
                    )
                    if finalCurvePoint == nil {
                        finalCurvePoint = prevCurveEnd
                        path.move(to: nextCurveBegin)
                    } else {
                        path.addQuadCurve(to: prevCurveEnd, control: prevControlPoint)
                        path.addLine(to: nextCurveBegin)
                    }
                }
            }
            prevControlPoint = point
        }
        if let finalCurvePoint, let prevControlPoint {
            path.addQuadCurve(to: finalCurvePoint, control: prevControlPoint)
        }
        path.closeSubpath()
        return path
    }
}

This allows the rounded diamond shape to be simplified to the following:

struct RoundedDiamond: Shape {
    var cornerRadius: CGFloat = 10

    func path(in rect: CGRect) -> Path {
        SimpleRoundedShape(
            points: [
                CGPoint(x: rect.midX, y: rect.minY),
                CGPoint(x: rect.maxX, y: rect.midY),
                CGPoint(x: rect.midX, y: rect.maxY),
                CGPoint(x: rect.minX, y: rect.midY)
            ],
            cornerRadius: cornerRadius
        )
        .path(in: rect)
    }
}

This produces the same results as before. Let's test it with another "pointy" shape too:

struct PointyShape: Shape {
    var cornerRadius: CGFloat = 10

    func path(in rect: CGRect) -> Path {
        SimpleRoundedShape(
            points: [
                CGPoint(x: rect.midX, y: rect.midY / 2),
                CGPoint(x: rect.maxX, y: rect.minY),
                CGPoint(x: rect.maxX * 3 / 4, y: rect.midY),
                CGPoint(x: rect.maxX, y: rect.maxY),
                CGPoint(x: rect.midX, y: rect.maxY * 3 / 4),
                CGPoint(x: rect.minX, y: rect.maxY),
                CGPoint(x: rect.midX / 2, y: rect.midY),
                CGPoint(x: rect.minX, y: rect.minY)
            ],
            cornerRadius: cornerRadius
        )
        .path(in: rect)
    }
}
PointyShape()
    .frame(width: 240, height: 180)
    .overlay {
        PointyShape(cornerRadius: 8)
            .stroke(.white, lineWidth: 3)
            .padding(.horizontal, 24)
            .padding(.vertical, 18)
    }
    .overlay {
        PointyShape(cornerRadius: 6)
            .fill(.yellow)
            .padding(.horizontal, 48)
            .padding(.vertical, 36)
    }
    .border(.red)

Screenshot

As you can see, the approach works, but sharp points are not rounded by much and are still quite sharp.

A better generic solution

A better approach would be to increase the distance from the control point when the corner is an acute angle, and in fact decrease the distance when it is an obtuse angle.

For a corner which is a right angle, the ideal distance between the start and end points of the curve would be cornerRadius * 2.squareRoot(). It works quite well to use this as the general case:

struct RoundedShape: Shape {
    let points: [CGPoint]
    var cornerRadius: CGFloat = 10

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let idealDistanceBetweenCurvePoints = cornerRadius * 2.squareRoot()
        let nPoints = points.count
        if nPoints > 2 {
            var prevPoint = points[nPoints - 1]
            var point = points[0]
            for index in 0..<nPoints {
                let nextPoint = points[index == nPoints - 1 ? 0 : index + 1]
                let dxPrev = point.x - prevPoint.x
                let dyPrev = point.y - prevPoint.y
                let dxNext = point.x - nextPoint.x
                let dyNext = point.y - nextPoint.y
                let lenPrev = ((dxPrev * dxPrev) + (dyPrev * dyPrev)).squareRoot()
                let lenNext = ((dxNext * dxNext) + (dyNext * dyNext)).squareRoot()
                if lenPrev > 0 && lenNext > 0 {
                    let offsetPrev = min(cornerRadius, lenPrev / 2)
                    let offsetNext = min(cornerRadius, lenNext / 2)
                    let xOffsetPrev = offsetPrev * (dxPrev / lenPrev)
                    let yOffsetPrev = offsetPrev * (dyPrev / lenPrev)
                    let xOffsetNext = offsetNext * (dxNext / lenNext)
                    let yOffsetNext = offsetNext * (dyNext / lenNext)
                    let xPrev = point.x - xOffsetPrev
                    let yPrev = point.y - yOffsetPrev
                    let xNext = point.x - xOffsetNext
                    let yNext = point.y - yOffsetNext
                    let dx = xNext - xPrev
                    let dy = yNext - yPrev
                    let distance = ((dx * dx) + (dy * dy)).squareRoot()
                    if distance > 0 {
                        let maxScalingFactor = min(
                            lenPrev / (2 * distance),
                            lenNext / (2 * distance)
                        )
                        let scalingFactor = min(
                            maxScalingFactor,
                            idealDistanceBetweenCurvePoints / distance
                        )
                        let curveBegin = CGPoint(
                            x: point.x - (xOffsetPrev * scalingFactor),
                            y: point.y - (yOffsetPrev * scalingFactor)
                        )
                        let curveEnd = CGPoint(
                            x: point.x - (xOffsetNext * scalingFactor),
                            y: point.y - (yOffsetNext * scalingFactor)
                        )
                        if index == 0 {
                            path.move(to: curveBegin)
                        } else {
                            path.addLine(to: curveBegin)
                        }
                        path.addQuadCurve(to: curveEnd, control: point)
                    } else {
                        if index == 0 {
                            path.move(to: point)
                        } else {
                            path.addLine(to: point)
                        }
                    }
                }
                prevPoint = point
                point = nextPoint
            }
        }
        path.closeSubpath()
        return path
    }
}

When RoundedShape is now used in place of SimpleRoundedShape in the diamond shape and pointy shape above, the test cases look like this:

Screenshot

Upvotes: 4

Related Questions