[SwiftUI] 角丸の多角形を描画する

# 汎用多角形Shape

辺の数、塗りつぶし色とボーダー色、ボーダーの幅、回転角度、角丸率 を引数で指定するだけで、角丸付きの多角形を描画します。
図形サイズは`frame`等で外側から指定します。


![scr14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/25757c65-efc8-38ba-fcb9-63a4673dbeea.png)



- 角丸にする方法は、次の記事を使わせていただきました。

https://qiita.com/yaslab/items/5bb1211ff3fe952af19c

- 角丸以外の多角形描画については、次の記事を使わせていただきました。

https://blog.eclypse.io/how-to-draw-a-polygon-in-swiftui-0db2e92a9991

<br>つまり、上の2つの記事で紹介されていたコードを合体させただけですので、次のコードの内容は、それぞれの記事を参照してください。

# 汎用多角形Shapeのコード

```swift:RoundedPolygon
public struct RoundedPolygon: Shape {
    public var numberOfSides: Int = 4 {
        didSet {
            if numberOfSides < 3 {
                numberOfSides = 3
            } else if numberOfSides > 180 {
                numberOfSides = 180
            }
        }
    }
    public var fillColor: Color = .clear
    public var rotationAngle: Angle = .zero
    public var borderWidth: CGFloat = 1.0
    public var borderColor: Color = .black
    public var ratio: CGFloat = 0.0
    
    public init(numberOfSides: Int = 4, fillColor: Color = .clear, rotationAngle: Angle = .zero, borderWidth: CGFloat = 1.0, borderColor: Color = .black, ratio: CGFloat = 0.0) {
        self.numberOfSides = numberOfSides
        self.fillColor = fillColor
        self.rotationAngle = rotationAngle
        self.borderWidth = borderWidth
        self.borderColor = borderColor
        self.ratio = ratio
    }

    public func path(in rect: CGRect) -> Path {
        let centerPoint = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2.0 - borderWidth / 2.0
        let initialPath = drawInitialPolygonPath(centerPoint: centerPoint, radius: radius, ratio: ratio)
        let scaledPath = scale(originalPath: initialPath, rect: rect, originalCenter: centerPoint, reCenter: true)
        return scaledPath
    }
    private func scale(originalPath: Path, rect: CGRect, originalCenter: CGPoint, reCenter: Bool) -> Path {
        let boundingRectOfRotatedPath = originalPath.boundingRect
        let scaleFactorX = rect.width / (boundingRectOfRotatedPath.width + 2 * borderWidth)
        let scaleFactorY = rect.height / (boundingRectOfRotatedPath.height + 2 * borderWidth)
        let finalScaleFactor = min(scaleFactorX, scaleFactorY)
        
        if abs(finalScaleFactor - 1.0) < 0.001 {
            return originalPath
        } else {
            let scaledAffineTransform = CGAffineTransform(scaleX: finalScaleFactor, y: finalScaleFactor)
            let scaledNonCenteredPath = originalPath.transform(scaledAffineTransform).path(in: rect)
            
            if reCenter {
                let centerAfterScaling = CGPoint(x: scaledNonCenteredPath.boundingRect.midX, y: scaledNonCenteredPath.boundingRect.midY)
                let recenteredAffineTransfor = CGAffineTransform(translationX: originalCenter.x - centerAfterScaling.x, y: originalCenter.y - centerAfterScaling.y)
                return scaledNonCenteredPath.transform(recenteredAffineTransfor).path(in: rect)
            } else {
                return scaledNonCenteredPath
            }
        }
    }

    public var body: some View {
        Canvas { context, size in
            let boundingRect = CGRect(origin: CGPoint.zero, size: size)
            let centerPoint = CGPoint(x: boundingRect.midX, y: boundingRect.midY)
            let radius = min(boundingRect.width, boundingRect.height) / 2.0 - borderWidth / 2.0
            
            let polygonPath = path(in: boundingRect)
            context.stroke(polygonPath, with: .color(borderColor), style: StrokeStyle(lineWidth: borderWidth))
            context.fill(polygonPath, with: .color(fillColor))
        }
    }
    
    private func drawInitialPolygonPath(centerPoint: CGPoint, radius: CGFloat, ratio: CGFloat) -> Path {
        let angleSliceFromCenter = 2 * .pi / CGFloat(numberOfSides)
        let points: [CGPoint] = (0 ..< numberOfSides).map { n in
            let currentAngleFromCenter = CGFloat(n) * angleSliceFromCenter
            let rotatedAngleFromCenter = currentAngleFromCenter + rotationAngle.radians - (.pi / 2)
            return CGPoint(
                x: centerPoint.x + radius * cos(rotatedAngleFromCenter),
                y: centerPoint.y + radius * sin(rotatedAngleFromCenter)
            )
        }

        var polygonPath = Path()
        if ratio == 0 {
            for n in 0 ..< points.count {
                if n == 0 {
                    polygonPath.move(to: points[n])
                } else {
                    polygonPath.addLine(to: points[n])
                }
            }
        } else {
            for n in 0 ..< points.count {
                let prev = n - 1 >= 0 ? n - 1 : points.count - 1
                let next = n + 1 < points.count ? n + 1 : 0
                let p1 = points[prev]
                let p2 = points[n]
                let p3 = points[next]
                
                let p12 = CGPoint(
                    x: (p1.x + p2.x) / 2 * ratio + p2.x * (1 - ratio),
                    y: (p1.y + p2.y) / 2 * ratio + p2.y * (1 - ratio)
                )
                let p23 = CGPoint(
                    x: (p2.x + p3.x) / 2 * ratio + p2.x * (1 - ratio),
                    y: (p2.y + p3.y) / 2 * ratio + p2.y * (1 - ratio)
                )
                
                if n == 0 {
                    polygonPath.move(to: p12)
                } else {
                    polygonPath.addLine(to: p12)
                }
                polygonPath.addQuadCurve(to: p23, control: p2)
            }
        }
        polygonPath.closeSubpath()
        return polygonPath
    }
}
```

なお、回転角度`rotationAngle = Angle(0)`のときに、0時を起点とするように変更してあります。


# 使用例

```swift
#Preview {
    let lightGray = Color(white: 0.85)
    return VStack(spacing: 20) {
        RoundedPolygon(numberOfSides: 3, fillColor: .red,
                       rotationAngle: Angle(degrees: -30), borderWidth: 30,
                       borderColor: Color(UIColor(Color.red).saturation(0.5)),
                       ratio: 0.3)
        .background(lightGray)
        RoundedPolygon(numberOfSides: 4, fillColor: .green,
                       rotationAngle: Angle(degrees: -45), borderWidth: 30,
                       borderColor: Color(UIColor(Color.green).saturation(0.5)),
                       ratio: 0.5)
        .background(lightGray)
        RoundedPolygon(numberOfSides: 5, borderWidth: 15,
                       borderColor: Color(UIColor(Color.blue).saturation(0.5)),
                       ratio: 0)
        .background(lightGray)
        RoundedPolygon(numberOfSides: 6, fillColor: .orange,
                       rotationAngle: Angle(degrees: -30), borderWidth: 30,
                       borderColor: Color(UIColor(Color.orange).saturation(0.5)))
        .background(lightGray)
    }
    .padding()
}
```

`Color(UIColor(Color.red).saturation(0.5))`の`saturation`が未定義となる場合は、↓前回の記事を参照してください。

https://qiita.com/nak435/items/f398352533ed3766fe51


# 環境

2024.6.12 執筆時の環境は以下のとおり

```terminal
$ /usr/bin/xcodebuild -version
Xcode 15.4
Build version 15F31d

$ swift --version
swift-driver version: 1.90.11.1 
Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
```