はじめに
昔の記事で、UIKit で星型の図形を作ってみたことがあったのですが、勉強ついでに SwiftUI でも試してみました。次のようなビューを作成してみます。
正五角形 | 正六角形 |
---|---|
正七角形 | 正八角形 |
---|---|
環境
- Xcode Version 11.5 (11E608c)
実装方針
星型の図形は、次の方法で作図します。
- 星型の多角形の外接円と内接円を作る
- 外接正多角形の頂点の数 n に対して、円を 2n 等分の扇形に分割する直線を引く
- 直線と外接円の交点、内接円の交点を交互に取得する
あとは、取得した頂点を直線で結ぶだけです。正五角形の場合をアニメーションで表すとこんな感じ。
基本的なデータ型を実装する
まず、SwiftUI に依存しないデータ構造を実装します。この準備をすることで、座標の位置計算など、SwiftUI フレームワークに依存しない処理を分離することができます。
import CoreGraphics
/// 極座標
struct PolarCoordinate {
/// 半径
var radius: Double
/// 角度( 弧度法 )
var angle: Double
init(radius: Double = 1, angle: Double = 0) {
self.radius = abs(radius)
self.angle = angle / (2 * .pi)
}
}
extension PolarCoordinate {
/// CoreGraphics の座標系における点
var cgPoint: CGPoint {
CGPoint(x: radius * cos(angle), y: radius * sin(angle))
}
}
extension PolarCoordinate {
/// 回転
func rotated(by angle: Double) -> Self {
var point = self
point.angle += angle
return point
}
/// 拡大・縮小
func scaled(by t: Double) -> Self {
var point = self
point.radius *= t
return point
}
}
PolarCoordinate
は極座標系における1点を表す構造体です。半径 radius
と角度 angle
をプロパティとして所持しています。
最終的には、平面上の点として扱うことになるので、次のように、半径と角度から CGPoint
を算出できるようにします。
var cgPoint: CGPoint {
CGPoint(x: radius * cos(angle), y: radius * sin(angle))
}
星型の図形は多角形なので、極座標の点の集まりと、それを任意の位置に平行移動させるための中心座標の組として表現します。次のような構造体 StarParameters
として実装します。
import CoreGraphics
/// 星の頂点を表現した構造体
struct StarParameters {
/// 中心座標
private(set) var center: CGPoint = .zero
/// 極座標系の点群
private(set) var points: [PolarCoordinate]
/// CoreGraphics の座標系における点群に変換する
var cgPoints: [CGPoint] {
let translatedByCenter = { [center] (point: CGPoint) in
CGPoint(x: point.x + center.x, y: point.y + center.y)
}
return points.lazy.map(\.cgPoint).map(translatedByCenter)
}
}
extension StarParameters {
/// 単位円周上で初期化する初期化子
///
/// - Parameters:
/// - vertex: 外側の頂点の数
/// - smoothness: 外接円と内接円の半径の比率
init(vertex: UInt = 5, smoothness: Double = 0.5) {
/// 内側も含めた頂点
let offests = (1...(2 * vertex))
/// 中心角
let rotation = (2 * .pi)/Double(offests.count)
/// 1つ目の頂点
let start = PolarCoordinate()
self.points = offests.reduce(into: [start]) { result, offest in
var last = result.last!
last.angle += rotation
last.radius = (offest % 2 == 1 ? smoothness : 1)
result += [last]
}
}
}
extension StarParameters {
/// 中心座標を設定します
func center(x: CGFloat, y: CGFloat) -> Self {
var parameters = self
parameters.center = CGPoint(x: x, y: y)
return parameters
}
/// 半径を設定します
func radius(_ radius: CGFloat) -> Self {
var parameters = self
parameters.points = points.map { $0.scaled(by: Double(radius)) }
return parameters
}
/// 回転角を設定します
func rotated(by angle: CGFloat) -> Self {
var parameters = self
parameters.points = points.map { $0.rotated(by: Double(angle)) }
return parameters
}
}
cgPoints
によって、平面上の点群を取得できるように実装しています。
たとえば、ある矩形領域内の中央に配置した星型の図形の頂点のリストは次のようにして取得することができます。
func starPoints(in rect: CGRect) -> [CGPoint] {
return StarParameters(vertex: 5, smoothness: 0.5) // 頂点の数, 滑らかさ
.center(x: rect.midX, y: rect.midY) // 内接円の中心
.radius(min(rect.midX, rect.midY)) // 内接円の半径
.rotated(by: -.pi/2) // 回転
.cgPoints
}
この CGPoint のリスト要素に対して、逐次、直線を引くことによって星型の図形を作図することができます。
ちなみに、これらのデータ構造は CoreGraphics フレームワークにのみ依存した実装なので、SwiftUI だけでなく UIKit でも再利用することができます。UIKit では、UIBezierPath で描画する場合の処理簡易化のために利用すると良いでしょう。
SwiftUI
Shape と Path
図形を SwiftUI のビューとして扱う場合は、Shape
プロトコルに適合します。Shape
は、与えた矩形に対して Path
を返す実装を要求します。
public protocol Shape : Animatable, View {
func path(in rect: CGRect) -> Path
}
ここで星型の Path
を作成することになります。Path
は図形の経路を表現した構造体です。
星型のような多角形は、直線で囲まれた閉じた経路として表現されるので、次の手順で作成します。
move(to:)
まず、図形の開始地点の頂点に移動します。
addLine(to:)
次に、次の頂点に向かって、逐次、線を引いていきます。
closeSubpath()
最後に経路を閉じます。
実装例
実際の実装は次の通りです。
import SwiftUI
/// 星型のシェイプ
struct StarShape: Shape {
/// 頂点の数
var vertex: UInt = 5
/// 滑らかさ
var smoothness: Double = 0.5
/// 回転角
var rotation: CGFloat = -.pi/2
func path(in rect: CGRect) -> Path {
Path { path in
let points: [CGPoint] = starPoints(in: rect)
path.move(to: points.first!)
points.forEach { point in
path.addLine(to: point)
}
path.closeSubpath()
}
}
/// 星型の頂点のリスト
///
/// - Parameter rect: 星型の外接円が内接する矩形領域
private func starPoints(in rect: CGRect) -> [CGPoint] {
return StarParameters(vertex: vertex, smoothness: smoothness)
.center(x: rect.midX, y: rect.midY)
.radius(min(rect.midX, rect.midY))
.rotated(by: rotation)
.cgPoints
}
}
Shape は View のサブタイプなので、PreviewProvider
でプレビューすることもできます。
#if DEBUG
struct StarPath_Previews: PreviewProvider {
static var previews: some View {
StarShape(vertex: 5, smoothness: 0.5)
}
}
#endif
プレビューによって、次のように表示されます。
プレビューでは、Attribute Inspector でプロパティを変更できて、変更ごとに図形が更新されるので面白いです。
内部の塗りつぶし
StarShape を Shape
として実装したことで、利用する側で自由に図形内部を塗りつぶすことができます。
塗りつぶしには、Shape
の fill
を使用します。
import SwiftUI
/// 星型のビュー
struct StarView<Style: ShapeStyle>: View {
/// 頂点の数
var vertex: UInt
/// 滑らかさ
var smoothness: Double
/// 塗りつぶし
var style: Style
var body: some View {
StarShape(vertex: vertex, smoothness: smoothness)
.fill(style)
.aspectRatio(1, contentMode: .fit)
}
}
PreviewProvider
は Group
を使って、まとめて表示確認ができて便利です。
#if DEBUG
struct StarView_Previews: PreviewProvider {
static var previews: some View {
Group {
StarView(vertex: 5, smoothness: 0.5, style: Color(red: 0, green: 1, blue: 1))
StarView(vertex: 6, smoothness: 0.6, style: Color(red: 1, green: 0, blue: 1))
StarView(vertex: 7, smoothness: 0.7, style: LinearGradient(gradient: .init(colors: [.pink, .orange]),
startPoint: .init(x: 0.5, y: 0), endPoint: .init(x: 0.5, y: 0.6)))
StarView(vertex: 8, smoothness: 0.8, style: RadialGradient(gradient: .init(colors: [.purple, .yellow]),
center: .center, startRadius: 0, endRadius: 75))
}
.previewLayout(.fixed(width: 160, height: 160))
}
}
#endif
内部と枠線の塗りつぶし
さて、Shape は内部の塗りつぶしだけでなく stroke
を使って、枠線を塗りつぶす事もできます。
StarShape(vertex: vertex, smoothness: smoothness)
.stroke(style, lineWidth: 4)
.aspectRatio(1.1, contentMode: .fit)
しかし、fill
と stroke
は同時にはできません。fill
や stroke
は Shape
を返さずに View
を返します。View
は Shape
と異なり、fill
や stroke
メソッドを持っていないため、これが原因でコンパイルエラーになります。
StarShape(vertex: vertex, smoothness: smoothness)
.fill(style1)
.stroke(style2, lineWidth: 4) // コンパイルエラー
.aspectRatio(1.1, contentMode: .fit)
この場合は、fill
と stroke
で別の View を作成して、それを重ね合わせて表示させると良さそうです。ZStack
を使って重ね合わせます。
struct StarView2: View {
/// 頂点の数
var vertex: UInt = 5
/// 滑らかさ
var smoothness: Double = 0.5
/// 内部の塗りつぶし
var fill: LinearGradient
/// 枠線の塗りつぶし
var stroke: LinearGradient
var body: some View {
let shape = StarShape(vertex: vertex, smoothness: smoothness)
return ZStack {
shape.fill(fill)
shape.stroke(stroke, lineWidth: 4)
}
.aspectRatio(1.1, contentMode: .fit)
}
}
枠線と内部の塗りつぶす LinearGradient
を適当に設定してプレビューしてみます。
実装はこんな感じです。
#if DEBUG
struct StarView2_Previews: PreviewProvider {
static var previews: some View {
Group {
StarView2(vertex: 5, smoothness: 0.5, fill: Styles.style1, stroke: Styles.style2)
StarView2(vertex: 6, smoothness: 0.6, fill: Styles.style2, stroke: Styles.style3)
StarView2(vertex: 7, smoothness: 0.7, fill: Styles.style3, stroke: Styles.style2)
StarView2(vertex: 8, smoothness: 0.8, fill: Styles.style4, stroke: Styles.style4)
}
.previewLayout(
PreviewLayout.fixed(width: 160, height: 160)
)
}
}
#endif
import SwiftUI
enum Styles {}
extension Styles {
static var style1: LinearGradient {
LinearGradient(
gradient: Gradient(colors: [
Color(red: 239/255, green: 120.0/255, blue: 221/255),
Color(red: 239/255, green: 172.0/255, blue: 120/255)
]),
startPoint: UnitPoint(x: 0.5, y: 0),
endPoint: UnitPoint(x: 0.5, y: 0.6)
)
}
static var style2: LinearGradient {
LinearGradient(
gradient: Gradient(colors: [
Color(red: 90/255, green: 177/255, blue: 187/255),
Color(red: 247/255, green: 221/255, blue: 114/255)
]),
startPoint: UnitPoint(x: 0.5, y: 0),
endPoint: UnitPoint(x: 0.5, y: 0.6)
)
}
static var style3: LinearGradient {
LinearGradient(
gradient: Gradient(colors: [
Color(red: 14/255, green: 107/255, blue: 168/255),
Color(red: 166/255, green: 225/255, blue: 250/255)
]),
startPoint: UnitPoint(x: 0.5, y: 0),
endPoint: UnitPoint(x: 0.0, y: 0.7)
)
}
static var style4: LinearGradient {
LinearGradient(
gradient: Gradient(colors: [
Color(red: 155/255, green: 120/255, blue: 116/255),
Color(red: 211/255, green: 62/255, blue: 67/255)
]),
startPoint: UnitPoint(x: 0.5, y: 0),
endPoint: UnitPoint(x: 0.5, y: 1)
)
}
}
#if DEBUG
struct Styles_Previews: PreviewProvider {
static var previews: some View {
Group {
Styles.style1.previewDisplayName("style1")
Styles.style2.previewDisplayName("style2")
Styles.style3.previewDisplayName("style3")
Styles.style4.previewDisplayName("style4")
}
.previewLayout(
PreviewLayout.fixed(width: 100, height: 100)
)
}
}
#endif
感想
プレビューしながら実装の内容が確認できるので、快適に実装できるように感じました。
普段はインターフェースビルダーを使うことが多いのですが、慣れてこれば SwiftUI の方が生産性が高くなりそうです。
UIKit と異なり、Path が構造体であることも印象が良かったです。
参照渡しだと、経路の変更が外部から容易に可能であるのに対して、値渡しならその心配もなくなるので、今回のようなサンプル実装だけでなく、実際に応用する際にハマりが少なくなりそうだと思いました。
iOS 14 からは、WidgetKit の登場によって、SwiftUI の導入が本格化されていくので、引き続き SwiftUI で遊んでみて色々と試していきたいところです。