The Util Designer
SwiftUI 使用InsettableShape畫正多邊形
xcode 13.4.1, swift 5.5, iOS 15.4
2022-08-13
SwiftUI 提供了Shape和InsettableShape兩個元件,使用這兩個元件可以大大簡化在繪圖的複雜度。雖然使用Shape就可以用來繪畫,但當stroke的lineWidth很寛時,會有一半的線條超出所顯示的範圍,就可以SwiftUI就提供了另一個prototype InsettableShape,只要實現InsettableShape的inset(by amount: CGFloat)就可以解決以上的問題。
1. 正多邊形的所有頂點是平均分布在用一個圖上,計算如下。
假計現在要畫一個5邊形,圓心在(x0, y0),圓的半徑為R,5個頂點平均分布在一個圓上,就是每兩個頂點之間相隔2π/5,所以第i個頂點會落在的角度為2π * i / 5上,
位置就落在(x : x0 + cos(2π * i / 5) * R, y : y0 + sin(2π * i / 5) * R)
最後把這些點依次連成一個閉圖。
import SwiftUI

struct Polygon : Shape {
    let numOfPoints : Int
    
    func path(in rect: CGRect) -> Path {
        let radius = min(rect.width, rect.height)/2
        let center = CGPoint(x : rect.width/2, y:rect.height/2)
        let eachOfAngle = 2.0*Double.pi/Double(numOfPoints)
        
        let points = (0..<numOfPoints).map { index -> CGPoint in
            let angle = -Double.pi/2.0 + eachOfAngle*Double(index)
            return CGPoint(x : center.x + cos(angle) * radius, y : center.y + sin(angle) * radius)
        }
        return Path { path in
            path.addLines(points)
            path.closeSubpath()
        }
    }
}

struct PolygonExample : View {
    var body: some View {
        Polygon(numOfPoints: 5)
    }
}
程式解釋:
numOfPoints:正多邊形的邊數
2. 自定議的Polygon可以使用Shape和InsettableShape已經提供的一些方法,比如stroke的顏色和寬度。
import SwiftUI

struct Polygon : Shape {
    let numOfPoints : Int
    
    func path(in rect: CGRect) -> Path {
        let radius = min(rect.width, rect.height)/2
        let center = CGPoint(x : rect.width/2, y:rect.height/2)
        let eachOfAngle = 2.0*Double.pi/Double(numOfPoints)
        
        let points = (0..<numOfPoints).map { index -> CGPoint in
            let angle = -Double.pi/2.0 + eachOfAngle*Double(index)
            return CGPoint(x : center.x + cos(angle) * radius, y : center.y + sin(angle) * radius)
        }
        return Path { path in
            path.addLines(points)
            path.closeSubpath()
        }
    }
}

struct PolygonExample : View {
    var body: some View {
        Polygon(numOfPoints: 5)
            .stroke(.blue, lineWidth: 50)
    }
}
3. 由上圖看到若線條寛度太寛會出現半條線條在外面,為了解決這個問題,將Polygon由承繼Shape改成承繼InsettableShape,並實現inset(by amount: CGFloat)就可以解決以上的問題,實作如下。
import SwiftUI

struct Polygon : InsettableShape {
    let numOfPoints : Int
    
    var insetAmount: CGFloat = 0
    
    func path(in rect: CGRect) -> Path {
        let radius = min(rect.width, rect.height)/2-insetAmount
        let center = CGPoint(x : rect.width/2, y:rect.height/2)
        let eachOfAngle = 2.0*Double.pi/Double(numOfPoints)
        
        let points = (0..<numOfPoints).map { index -> CGPoint in
            let angle = -Double.pi/2.0 + eachOfAngle*Double(index)
            return CGPoint(x : center.x + cos(angle) * radius, y : center.y + sin(angle) * radius)
        }
        return Path { path in
            path.addLines(points)
            path.closeSubpath()
        }
    }
    
    func inset(by amount: CGFloat) -> some InsettableShape {
        var polygon = self
        polygon.insetAmount += amount
        return polygon
    }
}

struct PolygonExample : View {
    var body: some View {
        Polygon(numOfPoints: 5)
            .inset(by: 25)
            .stroke(.blue, lineWidth: 50)
    }
}
4. 之後我們就可以覆用這個自創的元件。
import SwiftUI

struct PolygonExample : View {
    var body: some View {
        VStack {
            HStack {
                Polygon(numOfPoints: 5)
                    .inset(by: 5)
                    .stroke(.blue, lineWidth: 10)
                Polygon(numOfPoints: 6)
                    .inset(by: 5)
                    .fill(.teal)
            }
            HStack {
                Polygon(numOfPoints: 8)
                    .inset(by: 5)
                    .stroke(.orange, lineWidth: 10)
                Polygon(numOfPoints: 10)
                    .inset(by: 5)
                    .stroke(.teal, lineWidth: 10)
                    .overlay(
                        Polygon(numOfPoints: 10)
                            .inset(by: 12)
                            .fill(.blue.opacity(0.5))
                    )
            }
        }
    }
}