- 最小キャンバスの作成例
- View を @State にするのが良くない理由
- NSScreen.main?.backingScaleFactor ?? 2.0
- CGImage を CALayer に変換
- 「編集結果」を CGImage に戻す場合(参考)未確認
- 空のビットマップを作る
- Color と NSColor のキャスト
- penで使っていた描写コード
- 塗りつぶし関数
- CGImage → CALayer
- CALayer → CGImage
- CALayer.contents
- addSublayer()とは?
- レイヤーを追加する
- レイヤー順を変更する
- CGContext.makeImage()
- CGContext
- クリッピング領域とは
- 図形をレイヤーにする例
- 崩れないレイヤーを作る
- CGMutablePath()
- レイヤーにキーを付与する(Key-Value Coding (KVC) )
- レイヤーメソッド、プロパティ
- レイヤーを手前に出す
- CGAffineTransform
- CGRect(origin: .zero, size: rect.size) origin:について
- 図形に値を記憶させる
- レイヤーを拾う例
- 矩形、円の描写例
- path について
- レイヤーの座標プロパティ
- anchorPoint と position の違い
- CALayer から CGSize を取得
- undoManager 登録例
- 外部から undoManager を使う
- ハマりがちなこと
最小キャンバスの作成例
SwiftUI 側(中間処理)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct CanvasView: NSViewRepresentable { let cgImage: CGImage func makeNSView(context: Context) -> CanvasNSView { let view = CanvasNSView() view.setImage(cgImage) return view } func updateNSView(_ nsView: CanvasNSView, context: Context) { nsView.setImage(cgImage) } } |
updateNSView()が呼ばれるタイミング
1. 監視対象のプロパティが変更された時
あなたのコードでは以下が監視されています:
|
1 2 |
struct CanvasView: NSViewRepresentable { let cg_image: CGImage // ← これが変わると |
2. 親ビューが再描画された時
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct ContentView: View { @State private var someValue = 0 // ← これが変わると var body: some View { VStack { CanvasView(TSO: tso, cg_image: image) // ← updateNSView()が呼ばれる Button("Update") { someValue += 1 // 親が再描画 → 子のupdateも呼ばれる } } } } |
3. ビューが再表示された時
- ウィンドウがアクティブになった時
- タブを切り替えた時
- など
簡単なまとめ
updateNSView()は以下の時に呼ばれます:
- 受け取ったの任意のプロパティが変更された時
cg_imageが変更された時- 親ビューが再描画された時
重要ポイント:
- 思ったより頻繁に呼ばれる可能性がある
- 重い処理は条件付きで実行する(boolなどで判定して処理を走らせない(returnさせるとか))
- デバッグ用に
print()を入れて確認するのがおすすめ
NSView 本体
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
final class CanvasNSView: NSView { private let rootLayer = CALayer() private let imageLayer = CALayer() private let drawingLayer = CALayer() override init(frame frameRect: NSRect) { super.init(frame: frameRect) setupLayers() } required init?(coder: NSCoder) { super.init(coder: coder) setupLayers() } private func setupLayers() { wantsLayer = true layer = rootLayer rootLayer.backgroundColor = NSColor.windowBackgroundColor.cgColor rootLayer.isGeometryFlipped = true } func setImage(_ cgImage: CGImage) { imageLayer.contents = cgImage imageLayer.contentsGravity = .resizeAspect imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 let size = CGSize(width: cgImage.width, height: cgImage.height) imageLayer.bounds = CGRect(origin: .zero, size: size) imageLayer.position = CGPoint(x: size.width / 2, y: size.height / 2) drawingLayer.bounds = imageLayer.bounds drawingLayer.position = imageLayer.position if imageLayer.superlayer == nil { rootLayer.addSublayer(imageLayer) rootLayer.addSublayer(drawingLayer) } } override func layout() { super.layout() rootLayer.frame = bounds } } |
SwiftUI から使う
|
1 2 3 4 |
if let image = capture_image { CanvasView(cgImage: image) .frame(minWidth: 400, minHeight: 300) } |
layout()が呼ばれるタイミング
基本概念
layout()はビューのサイズや位置が変わった時に自動的に呼ばれるメソッドです。
override func layout() {
super.layout()
print("📐 layout() が呼ばれました")
// サブビューやレイヤーの配置を更新
}
呼ばれるタイミング一覧
1. ビューのサイズが変更された時
// ウィンドウをリサイズ
ユーザーがウィンドウの端をドラッグ
→ layout() が呼ばれる
// プログラムでサイズ変更
canvasView.frame.size = CGSize(width: 800, height: 600)
→ layout() が呼ばれる
2. ビューが初めて表示される時
// ビューが画面に追加された直後
let canvasView = CanvasNSView()
parentView.addSubview(canvasView)
→ layout() が呼ばれる
3. needsLayoutが設定された時
// 明示的にレイアウト更新を要求
canvasView.needsLayout = true
// 次の描画サイクルで layout() が呼ばれる
4. スクロールした時(場合による)
// NSScrollViewの中でスクロール
ユーザーがスクロール
→ 状況によっては layout() が呼ばれる
5. ウィンドウの状態が変わった時
// ウィンドウが最小化から復帰
// フルスクリーンのオン/オフ
// 別のディスプレイに移動
→ layout() が呼ばれる
パターン1: 初回表示
[アプリ起動]
↓
CanvasView.makeNSView() 呼び出し
↓
CanvasNSView を作成
↓
setImage() 呼び出し
↓
NSScrollView に追加
↓
📐 layout() が呼ばれる ← 初回
パターン2: ウィンドウリサイズ
[ユーザーがウィンドウを拡大]
↓
NSScrollView のサイズが変わる
↓
CanvasNSView のサイズも変わる
↓
📐 layout() が呼ばれる
パターン3: 画像変更
[新しい画像を読み込む]
↓
updateNSView() 呼び出し
↓
setImage() 呼び出し
↓
self.frame = ... でサイズ変更
↓
📐 layout() が呼ばれる
実験: 呼び出し回数を確認
override func layout() {
super.layout()
print("📐 layout() 呼び出し - frame: \(frame)")
root_layer.frame = bounds
// ... 既存のコード
}
出力例:
📐 layout() 呼び出し - frame: (0.0, 0.0, 800.0, 600.0)
[ウィンドウリサイズ]
📐 layout() 呼び出し - frame: (0.0, 0.0, 1000.0, 700.0)
[画像変更]
📐 layout() 呼び出し - frame: (0.0, 0.0, 1200.0, 900.0)
注意: 何度も呼ばれる可能性
layout()は頻繁に呼ばれる可能性があります:
// ❌ 重い処理は避ける
override func layout() {
super.layout()
// これは毎回実行される
for i in 0..<1000000 {
// 重い計算...
}
// → パフォーマンス低下!
}
// ✅ 軽い処理のみ
override func layout() {
super.layout()
// フレームの更新程度ならOK
root_layer.frame = bounds
shape_layer_root.frame = root_layer.bounds
}
layout() vs setImage()
| メソッド | 呼び出しタイミング | 用途 |
|---|---|---|
| setImage() | 画像を設定する時(1回) | 初期配置・画像変更時 |
| layout() | サイズ変更の度(複数回) | サイズ変更への対応 |
使い分けの例
func setImage(_ cg_image: CGImage) {
// 🔥 初期配置はここで
let imageSize = CGSize(width: cg_image.width, height: cg_image.height)
let canvasSize = CGSize(width: 1200, height: 900)
self.frame = CGRect(origin: .zero, size: canvasSize)
let center = CGPoint(x: canvasSize.width / 2, y: canvasSize.height / 2)
image_layer_root.bounds = CGRect(origin: .zero, size: imageSize)
image_layer_root.position = center
// ... 他のレイヤーも配置
}
override func layout() {
super.layout()
// 🔥 サイズ変更時の調整はここで
root_layer.frame = bounds
// レイヤーの位置を再計算(必要な場合)
// ※ 中央配置を維持したい場合は再計算が必要
}
よくあるパターン
パターンA: 一度だけ配置(シンプル)
// setImage()で完全に配置
func setImage(_ cg_image: CGImage) {
// すべての配置処理
}
// layout()は最小限
override func layout() {
super.layout()
root_layer.frame = bounds
}
パターンB: 動的に再配置(柔軟)
// setImage()は画像のみ
func setImage(_ cg_image: CGImage) {
image_layer_root.contents = cg_image
needsLayout = true // layout()を呼び出すよう要求
}
// layout()で毎回配置を計算
override func layout() {
super.layout()
// 現在のサイズに応じて中央配置を再計算
let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
image_layer_root.position = center
// ... 他のレイヤーも
}
デバッグ用コード
private var layoutCallCount = 0
override func layout() {
layoutCallCount += 1
print("📐 layout() 呼び出し回数: \(layoutCallCount)")
print(" frame: \(frame)")
print(" bounds: \(bounds)")
super.layout()
root_layer.frame = bounds
}
あなたのコードでのおすすめ
func setImage(_ cg_image: CGImage) {
// 画像サイズ
let imageSize = CGSize(width: cg_image.width, height: cg_image.height)
// キャンバスサイズ(大きめに)
let canvasSize = CGSize(width: 1200, height: 900)
self.frame = CGRect(origin: .zero, size: canvasSize)
// 中央位置
let center = CGPoint(x: canvasSize.width / 2, y: canvasSize.height / 2)
// すべてのレイヤーを配置
[image_layer_root, layered_images_root, drawing_layer_root,
shape_layer_root, text_layer_root].forEach { layer in
layer.bounds = CGRect(origin: .zero, size: imageSize)
layer.position = center
}
// 画像を設定
image_layer_root.contents = cg_image
// ...
}
override func layout() {
super.layout()
// root_layerだけ更新(他のレイヤーは setImage() で配置済み)
root_layer.frame = bounds
}
まとめ
| タイミング | 説明 | 頻度 |
|---|---|---|
| 初回表示 | ビューが画面に追加された時 | 1回 |
| サイズ変更 | ウィンドウリサイズなど | 複数回 |
| 画像変更 | setImage()でフレームが変わった時 | 画像変更時 |
| needsLayout | 明示的に要求した時 | 必要に応じて |
| ウィンドウ状態変化 | 最小化復帰、フルスクリーンなど | 時々 |
重要ポイント:
layout()は自動的に呼ばれる- 頻繁に呼ばれる可能性がある
- 重い処理は避ける
- レイアウトの調整が主な用途
View を @State にするのが良くない理由
|
1 2 3 4 5 |
// ❌ アンチパターン @State private var canvasView: CanvasView? // SwiftUIでは「データ」を状態管理し、「View」は描画を担当 // Viewを状態として保持するのは設計思想に反する |
作り直す、リセットするときは id()モディファイアを使う
|
1 |
.id(image) // または .id(UUID()) |
NSScreen.main?.backingScaleFactor ?? 2.0
NSScreen.main?.backingScaleFactor ?? 2.0
は、現在のメインディスプレイのバッキングスケールファクター(Backing Scale Factor)を取得するためのコードです。この値は、macOSアプリでの高解像度(Retina)ディスプレイのサポートにおいて重要です。
効果と役割
このコードの主な効果と役割は以下の通りです。
- 解像度の違いを吸収する: macOS (AppKit) では、画面の座標系はポイント (points) という論理的な単位で扱われます。一方、実際の描画が行われるバッキングストア(バックエンドのピクセルバッファ)は物理的なピクセル (pixels) で構成されます。
backingScaleFactorは、1ポイントあたりの物理ピクセル数を表します。 - Retina対応の判定:
- 手動描画の調整: 通常、ほとんどの標準的なUI要素や自動レイアウトでは、システムが自動的にスケーリングを処理するため、開発者がこの値を直接使用する必要はほとんどありません。しかし、OpenGLやカスタムの低レベルグラフィックスAPIを使用して手動で描画を行う場合や、キャッシュするビットマップ画像のサイズを決定する場合など、明示的なピクセル単位の制御が必要な稀なケースで、適切な解像度で描画するためにこの値を使用します。
- デフォルト値
?? 2.0:
まとめ
このコードは、現在の画面がRetinaディスプレイかどうかを判断し、適切なグラフィックスの描画スケールファクターを決定するためのものです。特にカスタム描画コードにおいて、鮮明な表示を実現するために使用されます。
CGImage を CALayer に変換
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func makeImageLayer(from cgImage: CGImage) -> CALayer { let layer = CALayer() layer.contents = cgImage layer.contentsGravity = .resizeAspect layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 layer.bounds = CGRect( x: 0, y: 0, width: cgImage.width, height: cgImage.height ) return layer } |
なぜこの設定が必要か(重要)
① contents = cgImage
- CALayer は CGImage を直接持てる
- NSImage 変換不要(高速)
② contentsGravity = .resizeAspect
- アスペクト比保持
- Windowsペイント的挙動
③ contentsScale
- Retina 対応(必須)
- これが無いと拡大時にボケる
④ bounds
- CALayer は frame ではなく bounds が重要
- 後で transform / zoom をかけるため
使い方例(Canvasに配置)
|
1 2 3 4 |
let imageLayer = makeImageLayer(from: cgImage) imageLayer.position = CGPoint(x: 0, y: 0) rootLayer.addSublayer(imageLayer) |
座標系の注意(macOS特有)
macOS の CALayer は:
- 原点:左下
- SwiftUI / NSView:左上
よくある対策
|
1 2 3 |
rootLayer.geometryFlipped = true //レイヤー初期化に書く |
👉 これを root に設定すると
マウス座標と描画が一致
拡大・縮小の準備
|
1 2 3 4 5 |
imageLayer.setAffineTransform( CGAffineTransform(scaleX: 1.0, y: 1.0) ) //キャンバスviewで?未確認 |
文字・線を載せる準備 未確認
|
1 2 3 |
let drawingLayer = CALayer() drawingLayer.bounds = imageLayer.bounds rootLayer.addSublayer(drawingLayer) |
👉 同じ bounds に揃えるのがコツ
「編集結果」を CGImage に戻す場合(参考)未確認
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func renderLayerToCGImage(layer: CALayer, size: CGSize) -> CGImage? { let colorSpace = CGColorSpaceCreateDeviceRGB() guard let ctx = CGContext( data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } layer.render(in: ctx) return ctx.makeImage() } |
空のビットマップを作る
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private func makeEmptyBitmap(size: CGSize, scale: CGFloat) -> CGImage? { let width = Int(size.width * scale) let height = Int(size.height * scale) guard let ctx = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } ctx.scaleBy(x: scale, y: scale) return ctx.makeImage() } |
Color と NSColor のキャスト
NSColor から SwiftUI Color への変換
|
1 2 3 4 |
import AppKit // macOSアプリの場合 let a = NSColor.systemBlue // 例としてシステムの青色を使用 let b = Color(nsColor: a) |
SwiftUI Color から NSColor への変換
|
1 2 3 4 |
import AppKit let a = Color.red let b = NSColor(a) |
penで使っていた描写コード
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// 実際に線を描く処理 private func draw(stroke: Stroke) { guard let baseImage = drawingImage else { return } // 書く場所のサイズとスケールをセット let scale = drawingLayer.contentsScale let size = drawingLayer.bounds.size guard let ctx = CGContext( data: nil, width: Int(size.width * scale), height: Int(size.height * scale), bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return } ctx.scaleBy(x: scale, y: scale) // 既存の描画をベースに ctx.draw(baseImage, in: CGRect(origin: .zero, size: size)) // 新しい線 ctx.setBlendMode(stroke.blendMode) ctx.setStrokeColor(stroke.color.cgColor) ctx.setLineWidth(stroke.width) ctx.setLineCap(.round) ctx.addLines(between: stroke.points) ctx.strokePath() ctx.setBlendMode(.normal) // 更新 let newImage = ctx.makeImage() drawingImage = newImage drawingLayer.contents = newImage } |
塗りつぶし関数
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
private func performFloodFill(at point: CGPoint) { guard let boundary = makeBoundaryImage(transparent: true) else { return } // ここに塗りつぶし対象の CGimage をセットする let scale = drawing_layer_root.contentsScale let pixel_point = CGPoint( x: point.x * scale, y: (drawing_layer_root.bounds.height - point.y) * scale // Y座標反転 Flippedしてなければ要らない ) guard let filled = floodFill( image: boundary, start: pixel_point, fill_color: TSO?.selected_color ?? .black ) else { return } // 塗り結果だけ反映 addCommand(.fill(filled)) } private func floodFill( image: CGImage, start: CGPoint, fill_color: NSColor ) -> CGImage? { let width = image.width let height = image.height let bytes_per_pixel = 4 let bytesPerRow = width * bytes_per_pixel guard let ctx = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) guard let buffer = ctx.data else { return nil } let ptr = buffer.bindMemory(to: UInt8.self, capacity: width * height * 4) func index(x: Int, y: Int) -> Int { (y * width + x) * bytes_per_pixel } let sx = Int(start.x) let sy = Int(start.y) // 範囲内かチェック if sx < 0 || sy < 0 || sx >= width || sy >= height { return nil } let start_index = index(x: sx, y: sy) let target_color = ( ptr[start_index], ptr[start_index + 1], ptr[start_index + 2], ptr[start_index + 3] ) // RGBAに変換 let fill = fill_color.usingColorSpace(.deviceRGB)! let fr = UInt8(fill.redComponent * 255) let fg = UInt8(fill.greenComponent * 255) let fb = UInt8(fill.blueComponent * 255) let fa: UInt8 = 255 if target_color == (fr, fg, fb, fa) { return nil } var stack = [(sx, sy)] while let (x, y) = stack.popLast() { // スタックから取り出す際にも範囲チェックが必要 guard x >= 0 && x < width && y >= 0 && y < height else { continue } let i = index(x: x, y: y) let current = ( ptr[i], ptr[i + 1], ptr[i + 2], ptr[i + 3] ) // ターゲット色と一致しない場合はスキップ if current != target_color { continue } // ピクセルを塗りつぶす ptr[i] = fr ptr[i + 1] = fg ptr[i + 2] = fb ptr[i + 3] = fa // 隣接ピクセルをスタックに追加 if x > 0 { stack.append((x - 1, y)) } if x < width - 1 { stack.append((x + 1, y)) } if y > 0 { stack.append((x, y - 1)) } if y < height - 1{ stack.append((x, y + 1)) } } return ctx.makeImage() } |
リッチバージョン(体感わからんかった、状況揃える必要ありそう)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
import AppKit // CanvasNSViewに追加するメソッド群 extension CanvasNSView { // mouseDown の .fill ケースを以下に置き換え func handleFillToolMouseDown(at point: CGPoint) { guard let targetColor = TSO?.selected_color else { return } // 塗りつぶし実行 performFloodFill(at: point, with: targetColor) } // フラッドフィル実装 private func performFloodFill(at point: CGPoint, with fillColor: NSColor) { guard let image = drawing_image else { return } let scale = drawing_layer.contentsScale let size = drawing_layer.bounds.size // ピクセル座標に変換(スケーリング考慮) let pixelX = Int(point.x * scale) let pixelY = Int(point.y * scale) let width = Int(size.width * scale) let height = Int(size.height * scale) // 範囲外チェック guard pixelX >= 0 && pixelX < width && pixelY >= 0 && pixelY < height else { return } // ピクセルデータを取得 guard var pixelData = getPixelData(from: image, width: width, height: height) else { return } // クリック位置の色を取得 let targetColor = getPixelColor( from: pixelData, x: pixelX, y: pixelY, width: width ) // 塗りつぶし色をRGBA形式に変換 let fillRGBA = colorToRGBA(fillColor) // 同じ色なら何もしない if targetColor == fillRGBA { return } // Undo用に元の画像を保存 let oldImage = drawing_image // フラッドフィルアルゴリズム実行 floodFillPixelData( &pixelData, startX: pixelX, startY: pixelY, targetColor: targetColor, fillColor: fillRGBA, width: width, height: height ) // 新しいCGImageを作成 guard let newImage = createImage( from: pixelData, width: width, height: height ) else { return } // 描画レイヤーに反映 drawing_image = newImage drawing_layer.contents = newImage // Undo登録 registerUndoForFloodFill(oldImage: oldImage, newImage: newImage) } // CGImageからピクセルデータを取得 private func getPixelData( from image: CGImage, width: Int, height: Int ) -> [UInt8]? { let bytesPerPixel = 4 let bytesPerRow = width * bytesPerPixel let totalBytes = height * bytesPerRow var pixelData = [UInt8](repeating: 0, count: totalBytes) guard let context = CGContext( data: &pixelData, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) return pixelData } // 指定座標のピクセル色を取得 private func getPixelColor( from pixelData: [UInt8], x: Int, y: Int, width: Int ) -> (r: UInt8, g: UInt8, b: UInt8, a: UInt8) { let bytesPerPixel = 4 let offset = (y * width + x) * bytesPerPixel return ( r: pixelData[offset], g: pixelData[offset + 1], b: pixelData[offset + 2], a: pixelData[offset + 3] ) } // NSColorをRGBAタプルに変換 private func colorToRGBA( _ color: NSColor ) -> (r: UInt8, g: UInt8, b: UInt8, a: UInt8) { // RGB色空間に変換 guard let rgbColor = color.usingColorSpace(.deviceRGB) else { return (0, 0, 0, 255) } return ( r: UInt8(rgbColor.redComponent * 255), g: UInt8(rgbColor.greenComponent * 255), b: UInt8(rgbColor.blueComponent * 255), a: UInt8(rgbColor.alphaComponent * 255) ) } // フラッドフィルアルゴリズム(スタックベース) private func floodFillPixelData( _ pixelData: inout [UInt8], startX: Int, startY: Int, targetColor: (r: UInt8, g: UInt8, b: UInt8, a: UInt8), fillColor: (r: UInt8, g: UInt8, b: UInt8, a: UInt8), width: Int, height: Int ) { let bytesPerPixel = 4 var stack: [(x: Int, y: Int)] = [(startX, startY)] while !stack.isEmpty { let (x, y) = stack.removeLast() // 範囲外チェック guard x >= 0 && x < width && y >= 0 && y < height else { continue } let offset = (y * width + x) * bytesPerPixel // 現在のピクセル色を取得 let currentColor = ( r: pixelData[offset], g: pixelData[offset + 1], b: pixelData[offset + 2], a: pixelData[offset + 3] ) // ターゲット色と一致しない、または既に塗りつぶし済みならスキップ guard currentColor == targetColor else { continue } // ピクセルを塗りつぶす pixelData[offset] = fillColor.r pixelData[offset + 1] = fillColor.g pixelData[offset + 2] = fillColor.b pixelData[offset + 3] = fillColor.a // 隣接ピクセルをスタックに追加(4方向) stack.append((x + 1, y)) // 右 stack.append((x - 1, y)) // 左 stack.append((x, y + 1)) // 下 stack.append((x, y - 1)) // 上 } } // ピクセルデータからCGImageを作成 private func createImage( from pixelData: [UInt8], width: Int, height: Int ) -> CGImage? { let bytesPerPixel = 4 let bytesPerRow = width * bytesPerPixel let colorSpace = CGColorSpaceCreateDeviceRGB() let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue guard let providerRef = CGDataProvider( data: Data(pixelData) as CFData ) else { return nil } return CGImage( width: width, height: height, bitsPerComponent: 8, bitsPerPixel: bytesPerPixel * 8, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: CGBitmapInfo(rawValue: bitmapInfo), provider: providerRef, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) } // Undo登録 private func registerUndoForFloodFill( oldImage: CGImage?, newImage: CGImage? ) { undoManager?.registerUndo(withTarget: self) { target in target.restoreDrawingImage(oldImage) } undoManager?.setActionName("塗りつぶし") } private func restoreDrawingImage(_ image: CGImage?) { let currentImage = drawing_image drawing_image = image drawing_layer.contents = image undoManager?.registerUndo(withTarget: self) { target in target.restoreDrawingImage(currentImage) } } } // mouseDown メソッドの .fill ケースを以下に変更: /* case .fill: handleFillToolMouseDown(at: point) return */ |
動作説明
- クリックした位置の色を取得
- 同じ色の連続した領域を検索(4方向:上下左右)
- 選択した色で塗りつぶす
- Undo対応
特徴
- ✅ スタックベースのアルゴリズム(再帰なしで安全)
- ✅ Undo/Redo対応
- ✅ 高解像度対応(Retinaディスプレイ対応)
- ✅ 透明度も考慮
パフォーマンス向上(オプション)
大きな画像で遅い場合は、許容誤差を追加できます:
|
1 2 3 4 5 6 7 8 9 10 11 |
// 色の比較に許容誤差を追加 private func colorsAreClose( _ color1: (r: UInt8, g: UInt8, b: UInt8, a: UInt8), _ color2: (r: UInt8, g: UInt8, b: UInt8, a: UInt8), tolerance: Int = 10 ) -> Bool { return abs(Int(color1.r) - Int(color2.r)) <= tolerance && abs(Int(color1.g) - Int(color2.g)) <= tolerance && abs(Int(color1.b) - Int(color2.b)) <= tolerance && abs(Int(color1.a) - Int(color2.a)) <= tolerance } |
CGImage → CALayer
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func makeImageLayer(from cgImage: CGImage) -> CALayer { let layer = CALayer() layer.contents = cgImage layer.contentsGravity = .resizeAspect layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 layer.bounds = CGRect( x: 0, y: 0, width: cgImage.width, height: cgImage.height ) return layer } |
CALayer → CGImage
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func renderLayerToCGImage(layer: CALayer, size: CGSize) -> CGImage? { let colorSpace = CGColorSpaceCreateDeviceRGB() guard let ctx = CGContext( data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } layer.render(in: ctx) return ctx.makeImage() } |
CALayer.contents
CALayer.contents の効果
基本的な効果
image_layer.contents = cg_image
効果: レイヤーが画像を表示するようになる
動作の流れ
1. 画像がない状態(初期状態)
let layer = CALayer()
layer.backgroundColor = NSColor.blue.cgColor
// → 青い四角形が表示される(画像なし)
┌─────────┐
│ │
│ 青色 │ ← backgroundColorのみ
│ │
└─────────┘
2. 画像を設定した状態
layer.contents = cgImage
// → 画像が表示される
┌─────────┐
│ 🖼️ │
│ 画像 │ ← contentsで設定した画像
│ │
└─────────┘
contentsに設定できるもの
| 型 | 用途 | 例 |
|---|---|---|
| CGImage | 画像表示(最も一般的) | layer.contents = cgImage |
| nil | 画像をクリア | layer.contents = nil |
| NSImage ❌ | 直接は不可 | CGImageに変換が必要 |
CGImageのみ使える理由
// ✅ これはOK
layer.contents = cgImage // CGImage型
// ❌ これはダメ
layer.contents = nsImage // NSImage型は直接使えない
// ✅ NSImageからCGImageに変換すればOK
if let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) {
layer.contents = cgImage
}
あなたのコードでの役割
全体の流れ
func setImage(_ cg_image: CGImage) {
// 1️⃣ レイヤーに画像を設定(これで画像が表示される!)
image_layer.contents = cg_image
// 2️⃣ 画像の表示方法を設定
image_layer.contentsGravity = .resizeAspect // アスペクト比を保持
// 3️⃣ Retinaディスプレイ対応
image_layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
// 4️⃣ レイヤーのサイズを画像サイズに合わせる
let size = CGSize(width: cg_image.width, height: cg_image.height)
image_layer.bounds = CGRect(origin: .zero, size: size)
// 5️⃣ レイヤーの位置を中央に
image_layer.position = CGPoint(x: size.width / 2, y: size.height / 2)
// 6️⃣ 描画レイヤーも同じサイズにセットアップ
setupdrawingLayer(size: size)
}
各プロパティの意味
contents – 何を表示するか
image_layer.contents = cg_image // ← この画像を表示
contentsGravity – どう配置するか
image_layer.contentsGravity = .resizeAspect
| 値 | 効果 |
|---|---|
.resizeAspect | アスペクト比を保持してフィット |
.resizeAspectFill | アスペクト比を保持して塗りつぶし |
.resize | レイヤーサイズに合わせて伸縮 |
.center | 中央に配置(サイズそのまま) |
contentsScale – 解像度
image_layer.contentsScale = 2.0 // Retina対応(2倍解像度)
効果: ぼやけない高解像度表示
// contentsScale = 1.0 の場合
┌─────┐
│ 🔲 │ ← ぼやける(低解像度)
└─────┘
// contentsScale = 2.0 の場合
┌─────┐
│ ⬛ │ ← くっきり(高解像度)
└─────┘
実験: contentsの有無による違い
パターンA: contentsなし
let layer = CALayer()
layer.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
layer.backgroundColor = NSColor.red.cgColor
// contents は nil(デフォルト)
表示: 赤い四角形のみ
パターンB: contentsあり
let layer = CALayer()
layer.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
layer.backgroundColor = NSColor.red.cgColor
layer.contents = someImage // ← 画像を設定
表示: 画像が表示される(backgroundColorは背後に)
パターンC: contentsのみ
let layer = CALayer()
layer.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
layer.contents = someImage // 画像のみ
// backgroundColor は nil
表示: 画像のみ(背景色なし、透明部分は透過)
よくある使い方
1. 画像レイヤー(あなたのコード)
let imageLayer = CALayer()
imageLayer.contents = cgImage // ← 背景画像として使用
2. 画像を更新
// 古い画像を新しい画像に置き換え
imageLayer.contents = newCGImage
// → 即座に表示が更新される
3. 画像をクリア
imageLayer.contents = nil // ← 画像を削除
// → backgroundColorのみ表示される
4. アニメーション
// contentsを変えることでアニメーションできる
CATransaction.begin()
CATransaction.setAnimationDuration(1.0)
imageLayer.contents = newImage
CATransaction.commit()
// → 1秒かけて画像が切り替わる
contents vs backgroundColor
| プロパティ | 用途 | 型 |
|---|---|---|
contents | 画像を表示 | CGImage? |
backgroundColor | 単色を表示 | CGColor? |
両方設定した場合: backgroundColorが背景、contentsが前面に表示
layer.backgroundColor = NSColor.blue.cgColor // 背景: 青
layer.contents = cgImage // 前面: 画像
// → 画像の透明部分から青色が見える
パフォーマンスの注意点
✅ 効率的
// 一度設定したらそのまま
imageLayer.contents = cgImage
❌ 非効率
// 毎フレーム更新は重い
func updateEveryFrame() {
imageLayer.contents = generateNewImage() // 毎回生成は遅い
}
💡 最適化
// 変更があった時だけ更新
func updateImage(_ newImage: CGImage) {
guard imageLayer.contents as? CGImage !== newImage else { return }
imageLayer.contents = newImage
}
まとめ
image_layer.contents = cg_image の効果
- レイヤーが画像を表示するようになる
- レイヤーのビジュアルコンテンツを設定
- 即座に画面に反映される
- 何度でも変更可能(画像の切り替え)
nilを設定すると画像がクリアされる(contents = nil)
あなたのコードでは
image_layer.contents = cg_image
これによりペイントアプリの背景画像がimage_layerに表示されます。その上に描画レイヤー、図形レイヤー、テキストレイヤーが重なる構造になっています。
┌─────────────────┐
│ text_layer │ ← テキスト
├─────────────────┤
│ shape_layer │ ← 図形
├─────────────────┤
│ drawing_layer │ ← ペン描画
├─────────────────┤
│ image_layer │ ← 背景画像(contents = cg_image)
└─────────────────┘
よく使う関連プロパティ
contents を設定する際、以下のプロパティを併用して表示を調整します。
contentsGravity:
画像のサイズがレイヤーと異なる場合に、どのように配置・拡大縮小するかを決定します(例:.resizeAspect,.center) [3]。contentsScale:
Retinaディスプレイなどの解像度に対応させるため、通常UIScreen.main.scaleを設定します。これを忘れると画像がぼやける原因になります [4]。contentsRect:
画像の一部だけを切り取って表示したい場合に使用します(0.0〜1.0 の範囲で指定) [1]。magnificationFilter: コンテンツが拡大・縮小される際のフィルタリング方法(画質)を制御します(例:.linear,.nearest)。
実装例 (Swift)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let myLayer = CALayer() myLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) // UIImage を CGImage に変換して代入 if let image = UIImage(named: "example")?.cgImage { myLayer.contents = image } // 画像の表示方法を「アスペクト比を維持して収める」に設定 myLayer.contentsGravity = .resizeAspect // デバイスの解像度を反映(重要!) myLayer.contentsScale = UIScreen.main.scale view.layer.addSublayer(myLayer) |
.addSublayer() との違い
CALayer.contents と .addSublayer() は、どちらも「レイヤーに何かを表示する」ためのものですが、「中身を塗る」か「子分を増やすか」という決定的な違いがあります。
ざっくり例えると、「1枚の画用紙(Layer)」に対して:
contents: 画用紙に直接写真をペタッと貼る(中身の書き換え).addSublayer(): 画用紙の上に、別の小さな紙を重ねて置く(階層の追加)
主な違いの比較
| 特徴 | contents (プロパティ) | addSublayer() (メソッド) |
|---|---|---|
| 本質 | レイヤーの「中身(画像)」を指定 | レイヤーの「子要素」を追加 |
| 階層構造 | 増えない(そのレイヤー自身の属性) | 増える(親子関係が形成される) |
| 主な用途 | 背景画像やアイコンの表示 | 複雑な図形、UIコンポーネントの構築 |
| メモリ/負荷 | 低い(単一の描画ユニット) | contentsよりは高い(管理対象が増える) |
| タッチ判定 | そのレイヤー1つで完結 | 子レイヤーごとに個別に判定可能 |
使い分けのイメージ
1. contents を使うとき
「このレイヤー自体に画像を表示させたい」という場合です。
- ボタンの背景に画像を入れたい
- アバターのアイコン画像を表示したい
- メリット: 構造がシンプルで、メモリ消費も最小限です。
2. addSublayer() を使うとき
「複数の要素を組み合わせて一つのパーツを作りたい」という場合です。
- 背景レイヤーの上に、影用のレイヤーや、枠線用のレイヤーを重ねたい
- 動くキャラクターの「腕」や「足」を別々のパーツとして制御したい
- メリット: 重なり順(Z順序)を自由に入れ替えたり、特定の子要素だけを個別にアニメーションさせたりできます。
併用することもあります
実務では、「親レイヤーに contents で背景画像を設定し、その上に addSublayer で装飾用のパーツを載せる」といった形で組み合わせて使うのが一般的です。
Apple公式: Core Animation Programming Guide では、レイヤーの階層構造(Layer Hierarchy)についての詳細が解説されています。
addSublayer()とは?
基本概念
addSublayer()はレイヤーの親子関係を作るメソッドです。
parentLayer.addSublayer(childLayer)
レイヤー階層のイメージ
root_layer (親)
├── image_layer (子)
├── drawing_layer (子)
├── shape_layer_root (子)
│ ├── shape1 (孫)
│ ├── shape2 (孫)
│ └── shape3 (孫)
└── text_layer_root (子)
├── text1 (孫)
└── text2 (孫)
重要な特徴
1. 表示されるための条件
レイヤーが画面に表示されるには:
- ✅ 親レイヤーに追加されている(
addSublayer()済み) - ✅ 親レイヤーが表示されている
- ✅ 自分自身が
isHidden = false
// ❌ 表示されない(どこにも追加されていない)
let layer = CAShapeLayer()
layer.path = somePath
// ✅ 表示される(親レイヤーに追加)
shape_layer_root.addSublayer(layer)
2. 重ね順(Z-order)
後から追加したレイヤーが上(手前)に表示されます。
root_layer.addSublayer(layerA) // 一番下
root_layer.addSublayer(layerB) // layerAの上
root_layer.addSublayer(layerC) // 一番上(最前面)
表示順序:
layerC ← 最前面(3番目に追加)
layerB ← 中間(2番目に追加)
layerA ← 最背面(1番目に追加)
3. 座標系
子レイヤーの座標は親レイヤーの座標系で解釈されます。
// 親レイヤーの(50, 50)の位置に配置
childLayer.frame = CGRect(x: 50, y: 50, width: 100, height: 100)
parentLayer.addSublayer(childLayer)
よく使うメソッド
レイヤーの追加・削除
// 追加
parentLayer.addSublayer(layer)
// 特定の位置に挿入
parentLayer.insertSublayer(layer, at: 0) // 一番下に挿入
parentLayer.insertSublayer(layer, above: otherLayer) // 指定レイヤーの上に
parentLayer.insertSublayer(layer, below: otherLayer) // 指定レイヤーの下に
// 削除
layer.removeFromSuperlayer() // 親から自分を削除
// 全ての子レイヤーを削除
parentLayer.sublayers?.forEach { $0.removeFromSuperlayer() }
// または
parentLayer.sublayers?.removeAll() // 配列をクリア(自動的に削除される)
レイヤーの移動(重ね順変更)
// 一度削除して再追加 → 最前面に
layer.removeFromSuperlayer()
parentLayer.addSublayer(layer)
// または
func bringToFront(_ layer: CALayer) {
layer.removeFromSuperlayer()
parentLayer.addSublayer(layer)
}
あなたのコードでの使用例
1. 初期設定(setupLayers())
// ルートレイヤーに子レイヤーを追加 → 基本構造を作る
root_layer.addSublayer(image_layer)
root_layer.addSublayer(drawing_layer)
root_layer.addSublayer(shape_layer_root)
root_layer.addSublayer(text_layer_root)
意味: 画面に表示されるレイヤー構造を構築
2. 図形の追加(mouseDown())
// 新しく作った図形をshape_layer_rootの子として追加
shape_layer_root.addSublayer(layer)
意味:
- 図形を管理用レイヤー(
shape_layer_root)の子にする - これにより図形が画面に表示される
shape_layer_rootは複数の図形をまとめて管理
3. 図形の削除(Undo時)
layer.removeFromSuperlayer() // 親(shape_layer_root)から削除
意味: レイヤーツリーから外す → 画面から消える
4. 図形を最前面に(移動モード)
func bringShapeLayerToFront(_ layer: CAShapeLayer) {
layer.removeFromSuperlayer() // 一旦削除
shape_layer_root.addSublayer(layer) // 再追加 → 最前面に
}
意味: 選択した図形を他の図形より手前に表示
よくある疑問
Q1: なぜshape_layer_rootに追加するの?
A: グループ管理のためです。
root_layer
└── shape_layer_root ← 図形専用の親レイヤー
├── shape1
├── shape2
└── shape3
メリット:
- すべての図形をまとめて操作できる
- 図形だけを非表示にできる
- 重ね順の管理が簡単
- レイヤー順序の変更が容易
Q2: addSublayer()しないとどうなる?
A: 画面に表示されません。
// これだけでは表示されない
let layer = CAShapeLayer()
layer.path = somePath
layer.strokeColor = NSColor.red.cgColor
// → どこにも追加されていないので見えない
// 親レイヤーに追加して初めて表示される
shape_layer_root.addSublayer(layer)
// → 表示される!
Q3: レイヤーを移動するには?
A: frameを変更するだけでOK(親子関係はそのまま)
// 既に追加済みのレイヤーを移動
layer.frame = CGRect(x: 100, y: 100, width: 50, height: 50)
// → 親子関係は変わらず、位置だけ変わる
まとめ
| 操作 | メソッド | 結果 |
|---|---|---|
| 表示する | parent.addSublayer(child) | 画面に表示される |
| 消す | child.removeFromSuperlayer() | 画面から消える |
| 最前面に | 削除 → 再追加 | 他のレイヤーより手前に |
| 位置変更 | child.frame = ... | 親子関係はそのまま移動 |
重要: addSublayer()は「レイヤーツリーに参加させる」という意味で、これによって初めて画面に表示され、イベントを受け取れるようになります。
レイヤーを追加する
ルートレイヤーにaddする
1. CanvasNSViewのsetupLayers()を修正
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private func setupLayers() { wantsLayer = true layer = root_layer root_layer.backgroundColor = NSColor.windowBackgroundColor.cgColor root_layer.isGeometryFlipped = true // レイヤー順序(下から上へ) root_layer.addSublayer(image_layer) root_layer.addSublayer(layered_images_root) // ← 追加! root_layer.addSublayer(drawing_layer) root_layer.addSublayer(shape_layer_root) root_layer.addSublayer(text_layer_root) } |
2. layout()メソッドに追加
|
1 2 3 4 5 6 7 |
override func layout() { super.layout() root_layer.frame = bounds shape_layer_root.frame = root_layer.bounds text_layer_root.frame = root_layer.bounds layered_images_root.frame = root_layer.bounds // ← 追加! } |
レイヤー順を変更する
addSublayer
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private func reorderLayers(_ order: [CanvasLayerOrder]) { let map: [CanvasLayerOrder: CALayer] = [ .image: image_layer, .drawing: drawing_layer, .shape: shape_layer_root, .text: text_layer_root ] // 一度全部外す for layer in map.values { layer.removeFromSuperlayer() } // 指定順に戻す for key in order { if let layer = map[key] { root_layer.addSublayer(layer) } } } |
配列の要素を入れ替えて操作
|
1 |
reorderLayers([.image, .drawing, .shape, .text]) |
zPosition
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
func applyLayerOrder(_ order: [CanvasLayerOrder]) { guard order != last_layer_order else { return } last_layer_order = order let map: [CanvasLayerOrder: CALayer] = [ .image: image_layer_root, .layered: layered_images_root, .drawing: drawing_layer_root, .shape: shape_layer_root, .text: text_layer_root ] // 🔥 removeFromSuperlayerを使わず、zPositionで順序を制御 for (index, key) in order.enumerated() { if let layer = map[key] { layer.zPosition = CGFloat(index) } } redrawAllCommands() } ``` ## zPositionとは? **レイヤーの重ね順(Z軸の位置)を制御するプロパティ**です: ``` zPosition = 0 ← 一番下 zPosition = 1 zPosition = 2 zPosition = 3 zPosition = 4 ← 一番上(最前面) ``` ## 動作イメージ ### ❌ 修正前(removeFromSuperlayer使用) ``` applyLayerOrder()呼び出し ↓ すべてのレイヤーをremoveFromSuperlayer() ↓ レイヤーの位置情報が失われる! ↓ addSublayer()で再追加 ↓ 位置が原点にリセットされた状態で追加 ``` ### ✅ 修正後(zPosition使用) ``` applyLayerOrder()呼び出し ↓ 各レイヤーのzPositionを変更 ↓ レイヤーは元の位置のまま、重ね順だけ変わる ↓ 位置情報は維持される! |
外部ViewのListで.onMoveを使ってレイヤー順を変更する
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
enum CanvasLayerOrder: Int, CaseIterable, Identifiable { case text case shape case drawing case add_images case image // Identifiableプロトコル用(必須) var id: Int { self.rawValue } // 表示名 var displayName: String { switch self { case .text: return "テキスト" case .shape: return "図形" case .drawing: return "ペン描画" case .add_images: return "追加画像" case .image: return "背景画像" } } var iconName: String { switch self { case .text: return "textformat" case .shape: return "square.on.circle" case .drawing: return "pencil" case .add_images: return "photo.stack" case .image: return "photo" } } } |
レイヤー順変更Viewを作成
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
struct LayerOrderView: View { @Bindable var TSO: ToolSettingsObject var body: some View { VStack { Text("レイヤー順(上が最前面)") .font(.headline) List { ForEach(TSO.layer_order) { layer in HStack { Image(systemName: layer.iconName) Text(layer.displayName) } } .onMove { source, destination in TSO.layer_order.move(fromOffsets: source, toOffset: destination) } } } .frame(width: 300, height: 400) } } |
メインViewに追加
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
struct ContentView: View { @State private var TSO = ToolSettingsObject() @State private var showLayerOrder = false var body: some View { HStack { CanvasView(TSO: TSO, cg_image: image, layered_images: $images) if showLayerOrder { LayerOrderView(TSO: TSO) } } .toolbar { Button("レイヤー") { showLayerOrder.toggle() } } } } ``` ## 動作の流れ ``` [ユーザーがListでレイヤーをドラッグ] ↓ .onMove が発火 ↓ TSO.layer_order.move(...) で配列を並び替え ↓ @Bindable により変更が検知される ↓ updateNSView() が自動的に呼ばれる ↓ canvas_view.applyLayerOrder(TSO.layer_order) が実行 ↓ 各レイヤーのzPositionが更新される ↓ 画面に反映! |
CGContext.makeImage()
CGContext.makeImage()
は、現在ビットマップコンテキストに描画されている内容を「スナップショット」として取り出し、CGImage オブジェクトを作成するためのメソッドです。
主な効果と特徴は以下の通りです:
1. 描画内容の静止画化
コンテキストに対して行った線、図形、テキスト、画像などの描画操作を、一つの独立した画像データ(CGImage)として生成します。
2. イミュータブル(不変)なコピーの作成
作成された CGImage は、その時点のコンテキストの状態を保持したコピーです。そのため、makeImage() を実行した後にコンテキストにさらに描画を加えても、出力済みの画像には影響しません。
3. メモリ効率の良い共有
Core Graphics は「Copy-on-write」の仕組みを利用するため、画像データが実際に変更されない限り、メモリを効率的に管理します。
4. 主な利用シーン
- カスタム描画の画像保存:
draw(_:)メソッド内などで描いたものをUIImageに変換して保存やシェアをする。 - 画像加工: 既存の画像にフィルタや合成処理を施した後、最終的な結果を取り出す。
- オフスクリーンレンダリング: 画面に表示させない裏側のコンテキストで画像を作成する。
実装例 (Swift)
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 1. コンテキストの作成 let renderer = UIGraphicsImageRenderer(size: CGSize(width: 100, height: 100)) let image = renderer.image { context in // 2. 描画操作 context.cgContext.setFillColor(UIColor.red.cgColor) context.cgContext.fill(CGRect(x: 0, y: 0, width: 50, height: 50)) // 3. makeImage() で CGImage を取得 (UIGraphicsImageRenderer の場合は自動で行われることが多いですが、手動で行う場合) if let cgImage = context.cgContext.makeImage() { // cgImage を使った処理 } } |
要約すると、CGContext.makeImage()は、コンテキスト内の描画結果を確定して画像データとして抽出するための重要な役割を果たします。
注意点:makeImage() は呼び出された時点でのビットマップをキャプチャするため、ループ内などで頻繁に呼び出すとパフォーマンスに影響を与える可能性があります。必要なタイミングで一度だけ呼び出すのが理想的です。
CGContext
CGContext は、いわば**「デジタル上のキャンバス」や「描画エンジン」**のような役割を果たします。
1. 引数の設定について
CGContext を作成する際、コンピュータに「どのような形式で色や透明度をメモリに保存するか」を伝える必要があります。
space: CGColorSpaceCreateDeviceRGB()
- 役割: **「色の基準(カラースペース)」**を決定します 。
- 内容: 「デバイス依存のRGB」を使用するという指定です。画面上で自然な色合い(赤、緑、青の組み合わせ)で描画するために標準的に使われます 。
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
- 役割: **「1ピクセルごとのデータ構造(透明度の扱い)」**を決定します 。
- premultiplied(乗算済みアルファ): 透明度を含む画像を描画する際、計算を高速化するためにあらかじめ「色値 × 透明度」を計算して保存する形式です。macOSやiOSの描画系(Core Graphics)で最も推奨される効率的な設定です。
- Last: データの並び順が「R, G, B, A(アルファ)」の順であることを示します。
2. ctx.draw の動作について
「この .draw は何度でも使用できるのですか?その際は上に被さるように描写される認識でいいですか?」
その通りです!認識は完全に合っています。
- 何度でも使用可能: コード内でも、ループ(
for layer in sortedLayers)の中で何度もctx.drawや独自の描画関数(renderContainerLayersなど)が呼ばれています 。 - 重ね塗り(画家のアルゴリズム): 後から実行された描画コマンドは、先に描画された内容の上に**上書き(上乗せ)**されます。
- 順序の重要性: そのため、添付コードでは
TSO?.layer_orderに基づいてレイヤーをソート(sortedLayers)し、正しい重なり順で描画されるように制御されています 。
補足:Core Graphics の座標系
コード内にコメントアウトされた部分がありますが 、Core Graphics(CGContext)は標準では左下が原点 (0,0)です。macOSの NSView の標準座標系と同じですが、画像データとして書き出す際に上下が反転して見える場合は、コンテキストの座標変換(scaleBy(x: 1.0, y: -1.0) など)が必要になることがあります。
|
1 2 3 4 5 6 7 8 9 |
guard let ctx = CGContext( data: nil, width: Int(size.width * scale), height: Int(size.height * scale), bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return } |
この瞬間に決まるもの
- ビットマップの 幅
- ビットマップの 高さ
- 描画可能な ピクセル領域
**「描画状態マシン」**が1つ生成される
以下の状態を内部に保持する:
- strokeColor
- fillColor
- lineWidth
- blendMode
- transform
- clip
- path
だからこう書ける
|
1 2 3 |
ctx.setStrokeColor(...) ctx.setLineWidth(...) ctx.setBlendMode(...) |
描画が起きるのはここだけ
|
1 2 3 4 5 6 |
ctx.strokePath() ctx.fillPath() ctx.draw(image, in: rect) ctx.stroke(rect) //👉 この瞬間にビットマップが破壊的に変更される |
Layer 側との関係
|
1 |
drawingLayer.contents = ctx.makeImage() |
drawingLayer.bounds と同サイズの image
Layer は image をそのまま表示
image 自体がはみ出していない
.textMatrix = .identity
CGContext.textMatrix = .identity
は、テキスト描画に使用されるテキスト行列をデフォルトの状態(恒等変換)にリセットする効果があります。これにより、テキストは回転、拡大縮小、傾斜などの変換が適用されていない、標準の向きとサイズで描画されます。
効果と重要性
- デフォルトのテキスト描画:
identity行列(変換なし)を設定することで、Core Graphicsのデフォルトの座標系に従ってテキストが描画されます。 - 描画の信頼性確保:
NSAttributedString.draw(in:)のような他の描画関数は、内部的にテキスト行列を変更することがあります。そのため、Core Graphics(またはCore Text)で直接テキストを描画する前に、この行を実行してテキスト行列を既知のデフォルト状態に戻すことが推奨されます。 - 予測可能な動作: テキスト描画の向きや位置が意図せず変更されるのを防ぎ、予測可能な描画結果を保証します。
- グラフィックスステートとは独立: テキスト行列はグラフィックスコンテキスト(
CGContext)の属性ですが、saveGState()やrestoreGState()のようなグラフィックスステートの保存・復元操作では保存されないことに注意が必要です。そのため、必要に応じて明示的に設定する必要があります。
まとめ
CGContext.textMatrix = .identity は、その時点で行列にどのような変更が加えられていたとしても、テキストが標準的でまっすぐな状態で描画されるようにするための初期設定またはリセット操作として機能します。
.saveGState()
CGContext.saveGState()
は、現在のグラフィックス状態のコピーをコンテキストのスタックに保存するために使用されます。これにより、以降の描画操作で変換(回転、拡大縮小、移動)、色、線幅などの属性を変更しても、後で元の状態に簡単に戻すことができます。
saveGState() の効果と使い方
- 状態の保存:
saveGState()が呼び出されると、その時点での描画設定(グラフィックス状態)がメモリ内のスタックにプッシュ(保存)されます。 - 一時的な変更: 保存後、コンテキストの設定(例:
context.translateBy(x: y:)やcontext.setFillColor(...)など)を自由に変更して描画を行うことができます。 - 状態の復元: 変更後の描画が完了したら、
context.restoreGState()を呼び出すことで、スタックの最上部にある保存された状態がポップ(復元)され、現在のグラフィックス状態が保存直前の状態に戻ります。
使用例
例えば、「一部の図形だけ45度回転させて赤色で塗りたい」という場合:
・saveGState() で今の状態を保存。
・座標を回転させ、色を赤に変更。
・描画する。
・restoreGState() を呼ぶ。
これで、「回転」や「赤色」の設定をいちいち手動で元に戻す(0度に戻す、元の色を再設定する)手間が省けます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
func drawExample(context: CGContext) { // 1. デフォルトの設定で何かを描画 // 2. 現在のグラフィックス状態を保存 context.saveGState() // // 3. 一時的に設定を変更(例: 色を赤に変更、回転を適用) context.setFillColor(UIColor.red.cgColor) context.rotate(by: .pi / 4) // 45度回転 // 4. 変更した設定で何かを描画 // ... // 5. グラフィックス状態を復元(色と回転が元の状態に戻る) context.restoreGState() // // 6. 復元された元の設定で次の描画を続ける // ... } |
注意点
- 描画内容は保存されない:
saveGState()が保存するのはあくまで「描画パラメータ」(色、変換行列、クリッピングパスなど)であり、それまでに描画された画像やパス自体は保存されません。 - ペアで使う:
saveGState()とrestoreGState()は常にセットで使用することが意図されています。ネストして使用することも可能です。
.restoreGState()
CGContext.restoreGState()
は、直前に保存したグラフィックスコンテキストの状態(グラフィックスステート)を復元するためのメソッドです。これ単体ではなく、必ず CGContext.saveGState() とセットで使われます。
効果と目的
- 描画設定の「元に戻す」を実現する:
saveGState()を呼び出した時点の線の太さ、色、変換行列(回転・拡大縮小・移動)、クリッピング領域、ブレンドモードなどの描画パラメータをスタックに保存します。 - 影響範囲を局所化する: 特定の描画処理(例: あるオブジェクトを描画するための回転や色の変更)の前後で
saveGState()とrestoreGState()を使用することで、その処理による描画設定の変更が、それ以降の他の描画に影響を与えないようにすることができます。 - 複雑な描画を簡素化する: 変換行列など、元に戻すのが難しい、あるいは不可能な設定変更を簡単に行うことができます。
使用例
複数の異なるスタイルで描画したい場合などに役立ちます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 1. デフォルトの状態(線の太さ 1.0、黒色など)で最初の描画を行う // 2. 現在のグラフィックスステートを保存する context.saveGState() // 3. グラフィックスステートを変更する(例: 線の太さを太く、色を赤に変更) context.setLineWidth(5.0) context.setStrokeColor(UIColor.red.cgColor) // ここで描画Aを行う -> 赤くて太い線で描画される // 4. 保存しておいたグラフィックスステートを復元する // (線の太さ 1.0、黒色に戻る) context.restoreGState() // 5. 復元されたデフォルトの設定で次の描画を行う // ここで描画Bを行う -> 黒くて細い線で描画される |
このように、saveGState() と restoreGState() のペアは、描画コンテキストの状態管理を容易にし、描画コードを整理するために不可欠な仕組みです。
.concatenate()
CGContext.concatenate(_:)
は、現在の描画コンテキストの CTM(現在の変形行列)に、指定した CGAffineTransform を掛け合わせる(合成する)メソッドです。
ひとことで言うと
「現在の状態を維持したまま、新しい変形(回転・拡大・移動など)を上乗せする」機能です。
効果
非可逆(描画状態の保存が必要): 一度 concatenate すると、元の座標系に戻すには逆行列をかけるか、context.saveGState() / context.restoreGState() を使って状態を復元する必要があります。
座標系の変更: 以降の描画すべてに、合成された変形が適用されます。
累積的: すでに scale や rotate がかかっている場合、その上からさらに変形が加わります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 1. 現在の状態を保存 context.saveGState() // 2. 変形を作成 let rotation = CGAffineTransform(rotationAngle: .pi / 4) // 45度 let translation = CGAffineTransform(translationX: 50, y: 0) // 移動 // 3. 合成(順序が重要:回転してから移動) let combinedTransform = rotation.concatenating(translation) // 4. コンテキストに適用 context.concatenate(combinedTransform) // 5. 描画(この描画は回転+移動の影響を受ける) context.fill(CGRect(x: 0, y: 0, width: 100, height: 100)) // 6. 元の状態に戻す context.restoreGState() |
注意点:計算の順序
CGAffineTransform の合成(concatenating)やコンテキストへの適用順序によって、結果が大きく変わります。
移動してから回転: 指定した場所へ移動した後、その地点で回転します。
回転してから移動: 回転した「軸」に沿って移動します。
クリッピング領域とは
「クリッピング領域」とは、一言でいうと「描画がはみ出さないように設定する、型抜き(マスク)の境界線」のことです。
「曲線の先端」のことではなく、「ここから外側には描画しないでね」という目に見えない壁を作るイメージです。
1. クリッピング領域のイメージ
クッキー作りを想像してみてください。
- 生地: キャンバス(描画対象)
- クッキーの型: クリッピングパス(クリッピング領域)
- 上からまぶす砂糖: 描画(色塗りや画像)
型を置いた状態で上から砂糖をまぶすと、型の内側だけに砂糖が残り、外側は綺麗なままですよね。これがクリッピングの効果です。
2. 具体的な使い方(Swift例)
例えば、四角い画像を「円形」に切り抜いて表示したい場合に便利です。
|
1 2 3 4 5 |
context.addEllipse(in: rect) // 円の形を作る context.clip() // その円を「クリッピング領域」に設定! // この後に描画するものは、すべて円の中にしか表示されない context.draw(image, in: rect) |
3. 「曲線の先端」については?
「曲線の先端(端っこ)」のデザインを制御する設定は、別にあります。
- Line Cap (ラインキャップ): 線の端を「ぶつ切り」にするか「丸く」するか。
- Line Join (ラインジョイン): 線と線の「つなぎ目」を角にするか丸くするか。
まとめ
- クリッピング領域: 「型抜き」のこと。特定の形の中だけに絵を描きたい時に使う。
- 曲線の先端: 「Line Cap」のこと。線の端っこの形状のこと。
saveGState() を使わずに clip() をしてしまうと、その後の描画がずっとその「型」の中に制限されてしまいます。なので、「この図形だけ型抜きしたい!」という時に、さっきの保存・復元がセットで活躍します。
図形をレイヤーにする例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// Stroke(線)モデル final class Stroke { var points: [CGPoint] let color: NSColor let width: CGFloat let blend_mode: CGBlendMode let tool: DrawingTool // 自作ツールの構造体 init(points: [CGPoint], color: NSColor, width: CGFloat, blend_mode: CGBlendMode, tool: DrawingTool ) { self.points = points self.color = color self.width = width self.blend_mode = blend_mode self.tool = tool } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
private func createRectangleLayer(from stroke: Stroke) { let p0 = stroke.points[0] let p1 = stroke.points[1] let rect = CGRect( x: min(p0.x, p1.x), y: min(p0.y, p1.y), width: abs(p1.x - p0.x), height: abs(p1.y - p0.y) ) let path = CGMutablePath() path.addRect(CGRect(origin: .zero, size: rect.size)) let layer = CAShapeLayer() layer.frame = rect // ← 超重要 layer.path = path layer.strokeColor = stroke.color.cgColor layer.fillColor = nil layer.lineWidth = stroke.width shapeLayerRoot.addSublayer(layer) shapeLayers.append(layer) registerUndoForShapeLayer(layer) } |
崩れないレイヤーを作る
回転後にドラッグすると frame が壊れる
原因は
「回転済みレイヤーの frame / bounds / position を移動基準にしている」
対策として、一枚レイヤーを被せて移動はそれで行う、変形は中身のレイヤーだけで行う。
なぜ 45° が一番ひどいのか
frame は:
「回転後の bounds を含む最小の axis-aligned 矩形」
です。
45° の正方形を想像すると:
- 回転するたびに
- 外接矩形のサイズが変わる
つまり:
回転 → frame 変化
frame.midX を基準に position 再設定
→ 中心がズレる
→ 次のドラッグでさらにズレる
これを繰り返している。
他にもレイヤーをコンテナ化にして中に変形レイヤーを持つメリット
「transform で移動すると、回転後に逆方向に動く」これの対策になる
コンテナクラスの実装例
これをインスタンス化して中身の【contentLayer】に変形を担当させる。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
final class ContainerLayer: CALayer { let contentLayer: CALayer init(content: CALayer) { self.contentLayer = content super.init() self.anchorPoint = CGPoint(x: 0.5, y: 0.5) self.addSublayer(content) } override init(layer: Any) { let other = layer as! ContainerLayer self.contentLayer = other.contentLayer super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } |
なぜこれで全て解決するか
| 問題 | 解決 |
|---|---|
| 回転後 frame が壊れる | frame を使わない |
| 45° でズレる | position は未回転 |
| 移動が逆になる | position は常に画面座標 |
| Undo | container + content で安定 |
| FloodFill 境界 | content.transform を使えばOK |
なぜ Adobe 系もこの方式か
- Illustrator
- Photoshop
- PowerPoint
- Figma
全部この構造
「動かす箱」と「変形する中身」を分ける
CGMutablePath()
CGMutablePath は、Swift(Core Graphics)で図形の通り道(パス)を作成・編集するための「設計図」のようなものです。
主な効果と使い道は以下の通りです。
1. 複雑な図形を自由に作れる
直線、曲線(ベジェ曲線)、円弧、長方形などを組み合わせて、標準のパーツにはない独自の形を描画できます。
- 効果:
move(to:)でペンを置き、addLine(to:)やaddCurve(to:)でなぞるように形を作れます。
2. メモリ効率の良い描画
一度作成したパスは、SKShapeNode(SpriteKit)や CAShapeLayer(UIKit/QuartzCore)に渡して再利用できます。
- 効果: 同じ図形を何度も計算し直す必要がなく、描画パフォーマンスが向上します。
3. 当たり判定(ヒットテスト)に使える
描画だけでなく、「特定の点(タップした場所)が図形の中にあるか」を判定するのに非常に便利です。
- 効果:
path.contains(point)を使うことで、不規則な形のボタンやキャラクターへのタッチ判定が正確に行えます。
4. 途中で「変更」が可能
CGPath(不変)とは異なり、CGMutablePath は作成した後から内容を追加・変更できます。
- 効果: アニメーションの途中で形を少しずつ変えたい場合などに、動的にパスを構築できます。
簡単な実装例
|
1 2 3 4 |
let path = CGMutablePath() path.move(to: CGPoint(x: 0, y: 0)) // スタート地点 path.addLine(to: CGPoint(x: 100, y: 100)) // 線を引く path.addEllipse(in: CGRect(x: 50, y: 50, width: 50, height: 50)) // 円を追加 |
レイヤーにキーを付与する(Key-Value Coding (KVC) )
Swiftの CALayer は KVC に対応しているため、動的に新しいキーと値を追加できます。サブクラスを作るほどではない場合に便利です。
|
1 2 3 4 5 6 7 8 9 |
// セットする layer.setValue(stroke.tool == .rectangle ? "rect" : "circle", forKey: "shapeKind") // 判別する if let kind = layer.value(forKey: "shapeKind") as? String { if kind == "rect" { // 矩形の処理 } } |
レイヤーメソッド、プロパティ
.removeFromSuperlayer()
表示を一旦クリアにする
オブジェクトは消えない
.name
CALayer には標準で name: String? というプロパティがあります。これに識別用の文字列を入れる方法です。
|
1 2 3 4 5 6 |
layer.name = (stroke.tool == .rectangle) ? "rectangle" : "circle" // 判別時 if layer.name == "rectangle" { print("これは矩形です") } |
レイヤーを手前に出す
|
1 2 3 4 5 6 7 8 9 10 11 |
private func bringShapeLayerToFront(_ container: ContainerLayer) { container.removeFromSuperlayer() if container.contentLayer is CAShapeLayer { shape_layer_root.addSublayer(container) } else if container.contentLayer is CATextLayer { text_layer_root.addSublayer(container) } else { add_images_root.addSublayer(container) } } |
CGAffineTransform
Swiftの
CGAffineTransformは、2Dグラフィックスにおける「変形(アフィン変換)」を扱うための構造体です。
UIViewのtransformプロパティなどに設定することで、ビューの回転・拡大縮小・移動を簡単に行うことができます。
主な役割
元の画像やビューの形を保ったまま、以下の3つの操作(およびその組み合わせ)を実行します。
- Translation(移動): 上下左右に動かす。
- Scale(拡大・縮小): 大きさを変える。
- Rotation(回転): 角度を変える。
基本的な使い方
1. 移動 (Translation)
現在の位置から指定した距離だけ移動させます。
|
1 2 |
// 右に100、下に50ポイント移動 view.transform = CGAffineTransform(translationX: 100, y: 50) |
2. 拡大・縮小 (Scale)
倍率を指定してサイズを変更します。
|
1 2 |
// 縦横を2倍に拡大 view.transform = CGAffineTransform(scaleX: 2.0, y: 2.0) |
3. 回転 (Rotation)
指定した角度(ラジアン)で回転させます。
|
1 2 |
// 45度回転させる (角度 * .pi / 180 で計算) view.transform = CGAffineTransform(rotationAngle: CGFloat(45 * Double.pi / 180)) |
4. リセット (Identity)
変形を解除して元の状態に戻すには、.identity を使用します。
|
1 2 |
// 元の状態に戻す view.transform = .identity |
複数の変形を組み合わせる (Concatenating)
移動させてから回転させるなど、複数の変形を連結できます。
|
1 2 3 4 5 |
let translation = CGAffineTransform(translationX: 100, y: 0) let rotation = CGAffineTransform(rotationAngle: .pi / 4) // 移動と回転を組み合わせる view.transform = translation.concatenating(rotation) |
補足
CGAffineTransform は「端をドラッグして広げる」タイプのリサイズにはあまり向きません。
その理由は、CGAffineTransform が行うのはあくまで**「図形全体の拡大縮小(スケーリング)」**だからです。
リサイズ関数
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
import AppKit // リサイズ速度を調整する方法 extension CanvasNSView { // ======================================== // 方法1: 感度係数を使う(シンプル・推奨) // ======================================== func handleResizeMouseDragged_Fast(point: CGPoint) { guard let layer = retention_layer, base_distance > 0 else { return } // 現在の距離を計算 let currentDistance = distanceBetween( point1: rotation_center, point2: point ) // スケール比率を計算 let baseScale = currentDistance / base_distance // 🔥 感度係数を適用(2倍速の例) let sensitivity: CGFloat = 2.0 // 1.0 = 通常、2.0 = 2倍速、0.5 = 半分 // 1.0を基準に差分を計算して感度を適用 let scaleDelta = (baseScale - 1.0) * sensitivity let scale = 1.0 + scaleDelta // 最小・最大スケールを制限 let clampedScale = max(0.1, min(scale, 5.0)) // 基本変形にスケールを適用 let t = base_trans_form.scaledBy(x: clampedScale, y: clampedScale) applyTransform(to: layer, transform: t) } // ======================================== // 方法2: 基準距離を短くする(より敏感に) // ======================================== func handleResizeMouseDown_Sensitive(point: CGPoint) { if let layer = shapeLayer(at: point) { retention_layer = layer start_point = point prepareLayerForTransform(layer) rotation_center = CGPoint( x: layer.frame.midX, y: layer.frame.midY ) // 実際の距離を計算 let actualDistance = distanceBetween( point1: rotation_center, point2: point ) // 🔥 基準距離を短くする(例: 実際の50%) base_distance = actualDistance * 0.5 // これにより、同じドラッグ量で2倍のスケール変化になる base_trans_form = layer.value(forKey: "affineTransform") as? CGAffineTransform ?? .identity bringShapeLayerToFront(layer) showSelectionFrame(for: layer) } } // ======================================== // 方法3: 累積方式(スムーズで自然) // ======================================== // 追加のプロパティが必要 // private var last_drag_point: CGPoint? func handleResizeMouseDragged_Cumulative(point: CGPoint) { guard let layer = retention_layer else { return } // 前回の位置がなければ保存して終了 guard let lastPoint = last_drag_point else { last_drag_point = point return } // 前回からの移動量を計算 let dx = point.x - lastPoint.x let dy = point.y - lastPoint.y let movement = sqrt(dx * dx + dy * dy) // 🔥 感度係数(この値を大きくすると速くなる) let sensitivity: CGFloat = 0.01 // 0.005 = 遅い、0.01 = 普通、0.02 = 速い // 中心から外向きか内向きかを判定 let centerToCurrent = distanceBetween( point1: rotation_center, point2: point ) let centerToLast = distanceBetween( point1: rotation_center, point2: lastPoint ) let direction: CGFloat = centerToCurrent > centerToLast ? 1.0 : -1.0 // スケール変化量を計算 let scaleChange = 1.0 + (movement * direction * sensitivity) // 現在の変形を取得 let currentTransform = layer.value(forKey: "affineTransform") as? CGAffineTransform ?? .identity // スケールを累積的に適用 let newTransform = currentTransform.scaledBy(x: scaleChange, y: scaleChange) // スケールの制限(トータルで0.1〜5倍) let currentScale = sqrt(abs(newTransform.a * newTransform.d)) if currentScale >= 0.1 && currentScale <= 5.0 { applyTransform(to: layer, transform: newTransform) } // 現在位置を保存 last_drag_point = point } func handleResizeMouseUp_Cumulative() { last_drag_point = nil // リセット // ... 既存のmouseUp処理 } // ======================================== // 方法4: 設定可能な感度(ユーザーが調整可能) // ======================================== // ToolSettingsObjectに追加: // var resize_sensitivity: CGFloat = 1.0 func handleResizeMouseDragged_Configurable(point: CGPoint) { guard let layer = retention_layer, base_distance > 0 else { return } let currentDistance = distanceBetween( point1: rotation_center, point2: point ) let baseScale = currentDistance / base_distance // 🔥 ユーザー設定可能な感度を使用 let sensitivity = TSO?.resize_sensitivity ?? 1.0 let scaleDelta = (baseScale - 1.0) * sensitivity let scale = 1.0 + scaleDelta let clampedScale = max(0.1, min(scale, 5.0)) let t = base_trans_form.scaledBy(x: clampedScale, y: clampedScale) applyTransform(to: layer, transform: t) } } // ======================================== // 各方法の比較とサンプル値 // ======================================== /* 方法1: 感度係数(推奨) ───────────────────── sensitivity = 1.0 // 通常速度 sensitivity = 1.5 // 1.5倍速 sensitivity = 2.0 // 2倍速 sensitivity = 3.0 // 3倍速 sensitivity = 0.5 // 半分の速度 メリット: シンプル、直感的 デメリット: なし ───────────────────── 方法2: 基準距離を短縮 ───────────────────── base_distance = actualDistance * 1.0 // 通常 base_distance = actualDistance * 0.5 // 2倍速 base_distance = actualDistance * 0.33 // 3倍速 base_distance = actualDistance * 0.25 // 4倍速 メリット: シンプル デメリット: クリック位置で感度が変わる ───────────────────── 方法3: 累積方式 ───────────────────── sensitivity = 0.005 // 遅い sensitivity = 0.01 // 普通 sensitivity = 0.015 // やや速い sensitivity = 0.02 // 速い sensitivity = 0.03 // とても速い メリット: 最も自然で滑らか デメリット: 実装がやや複雑 ───────────────────── 方法4: ユーザー設定可能 ───────────────────── UIにスライダーを追加: 1.0x | 1.5x | 2.0x | 2.5x | 3.0x メリット: ユーザーが好みに調整できる デメリット: UIが必要 */ // ======================================== // おすすめの実装(方法1) // ======================================== /* // 既存のmouseDraggedの.resizeケースを以下に置き換え: case .resize: guard let layer = retention_layer, base_distance > 0 else { super.mouseDragged(with: event) return } let currentDistance = distanceBetween( point1: rotation_center, point2: point ) let baseScale = currentDistance / base_distance // 🔥 この値を変更して速度調整 let sensitivity: CGFloat = 2.0 // 2倍速にする let scaleDelta = (baseScale - 1.0) * sensitivity let scale = 1.0 + scaleDelta let clampedScale = max(0.1, min(scale, 5.0)) let t = base_trans_form.scaledBy(x: clampedScale, y: clampedScale) applyTransform(to: layer, transform: t) */ |
CGRect(origin: .zero, size: rect.size) origin:について
CGRect(origin: .zero, size: rect.size)
における origin: は、その CGRect の左上隅の座標を定義するものです。
origin: に .zero を指定することは、以下の座標を設定することと同じ意味になります。
x座標:0y座標:0
これは、新しく作成される矩形が、親となるビューや座標系の左上隅(原点)から始まることを意味します。
使用例
このコードは、多くの場合、描画コンテキスト全体をカバーする矩形や、ビューの左上から始まる矩形を作成する際に使用されます。
例えば、draw(_:) メソッド内で現在のビュー全体を描画したい場合、引数として渡される rect(描画可能な領域のサイズを持つ)を使用して、原点から始まる新しい CGRect を作成します。
図形に値を記憶させる
|
1 2 3 4 5 6 7 8 9 |
// 文字列の保存 レイヤー変数.setValue("保存させたい文字列", forKey: "キー") // 値 レイヤー変数.setValue(変数, forKey: "キー") // 文字列呼び出し let 変数 = レイヤー変数.value(forKey: "キー") as? String // 値呼び出し let 変数 = レイヤー変数.value(forKey: "キー") as? CGFloat ?? 0 // 呼び出したい型を as? の後ろに書く |
レイヤーを拾う例
|
1 2 3 4 5 6 7 8 |
private func textLayer(at point: CGPoint) -> CATextLayer? { for layer in textLayers.reversed() { // 前面優先 if layer.frame.contains(point) { return layer } } return nil } |
矩形、円の描写例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
override func mouseDragged(with event: NSEvent) { guard let stroke = currentStroke else { return } var point = convert(event.locationInWindow, from: nil) let start = stroke.points.first! let isShift = event.modifierFlags.contains(.shift) switch stroke.tool { case .pen, .eraser: stroke.points.append(point) case .line: if isShift { let dx = abs(point.x - start.x) let dy = abs(point.y - start.y) if dx > dy { point.y = start.y // 水平 } else { point.x = start.x // 垂直 } } updateSecondPoint(of: stroke, with: point) case .rectangle, .ellipse: if isShift { let dx = point.x - start.x let dy = point.y - start.y let size = max(abs(dx), abs(dy)) point = CGPoint( x: start.x + (dx >= 0 ? size : -size), y: start.y + (dy >= 0 ? size : -size) ) } updateSecondPoint(of: stroke, with: point) } redrawPreview(stroke) } |
|
1 2 3 4 5 6 7 |
private func updateSecondPoint(of stroke: Stroke, with point: CGPoint) { if stroke.points.count == 1 { stroke.points.append(point) } else { stroke.points[1] = point } } |
path について
CAShapeLayer における path は、そのレイヤーが描画する「図形の設計図」のようなものです。
CALayer(通常のレイヤー)は単なる四角い枠ですが、CAShapeLayer はその枠の中に 「ベジェ曲線(CGPath)」 という数学的な線を描くことができます。
1. Frame(枠)と Path(線)の違い
ここを理解するのが一番の近道です。
frame(CGRect): レイヤーが占有する「四角い領域」です。マウスイベントが反応する範囲や、背景色を塗る範囲を決めます。path(CGPath): その領域の中に描かれる「具体的な形」です。
例えば、100×100の正方形の frame を持つレイヤーの中に、円形の path を描いた場合を考えてみましょう。
- 角の部分は
frameには含まれますが、pathの中には含まれません。
2. なぜ Path を使うのか?
CAShapeLayer は path プロパティにパスを渡すだけで、自動的に滑らかな図形を描画してくれます。
|
1 2 3 4 5 6 7 8 9 10 |
let layer = CAShapeLayer() let rect = CGRect(x: 0, y: 0, width: 100, height: 100) layer.frame = rect // 円形の設計図(Path)を作る let path = CGPath(ellipseIn: CGRect(origin: .zero, size: rect.size), transform: nil) // レイヤーに設計図を渡す layer.path = path layer.fillColor = NSColor.blue.cgColor // 内部を青く塗る |
3. 「当たり判定」での Path の強力さ
path を使うと「正確な図形の形」でマウス判定ができます。
Frameでの判定(大まか)
layer.frame.contains(point) を使うと、円の図形であっても、角の透明な部分をクリックした時に反応してしまいます。
Pathでの判定(正確)
layer.path?.contains(point) を使うと、**「円の内側をクリックしたときだけ」**反応させることができます。
|
1 2 3 4 |
// point はマウスの座標 if let path = layer.path, path.contains(point) { print("図形の形の中を正確にクリックしました!") } |
4. パスの種類 (CGPath)
よく使うパスの作り方は以下の通りです。
| 図形 | 作り方 (Swift) |
| 矩形 | CGPath(rect: rect, transform: nil) |
| 角丸矩形 | CGPath(roundedRect: rect, cornerWidth: 10, cornerHeight: 10, transform: nil) |
| 円・楕円 | CGPath(ellipseIn: rect, transform: nil) |
| 自由な線 | CGMutablePath() を作って move(to:) や addLine(to:) を使う |
まとめ:設計図としての Path
CAShapeLayer にとって、frame は「キャンバスの場所と大きさ」であり、path は「そこに何を描くかの指示書」です。
現在のコードでは stroke.tool に応じて path.addRect や path.addEllipse を使い分けて設計図を作っています。これがまさに path の基本操作です!
レイヤーの座標プロパティ
一言でいうと、frame/bounds/position は「箱(容器)」の設定、path は「中身の形(絵)」の設定です。
1. 各プロパティの役割
| プロパティ | 役割 | 座標系 |
frame | **「親レイヤーから見た」**自分の位置とサイズ | 親の座標系 |
bounds | **「自分自身から見た」**描画範囲のサイズ | 自分の座標系 |
position | **「親レイヤー内での」**自分の基準点の位置 | 親の座標系 |
path | **「自分の中に描く」**図形の具体的な設計図 | 自分の座標系 |
2. 具体的なイメージ
frame (CGRect)
- レイヤーを配置する「外枠」です。
frame.originは親要素の左下(macOSの場合)からの距離を示します。- 注意: レイヤーを回転させたり拡大縮小(transform)したりすると、この値は正しく取得できなくなります。
bounds (CGRect)
- レイヤー内部の「キャンバスの大きさ」です。
- 通常、
originは(0, 0)です。 - もし
bounds.sizeをframe.sizeより小さくすると、はみ出たpathは(masksToBoundsが true の場合)切り取られます。
position (CGPoint)
- レイヤーの「基準点(AnchorPoint)」が、親のどこにあるかを示します。
- デフォルトではレイヤーの中心(
anchorPoint = (0.5, 0.5))を指します。 frame.originを動かすのと、positionを動かすのは、結果として同じ「移動」になりますが、回転や拡大の中心点として使われるのがこのpositionです。
path (CGPath)
- これが
CAShapeLayerの本体です。 - 重要なルール:
pathの座標はboundsの中での位置 を指定します。 - 例:
frameが(100, 100, 50, 50)のレイヤーに、(0, 0, 50, 50)のpathを描くと、画面の(100, 100)の位置に図形が表示されます。
もし frame を設定せずに path だけを設定するとどうなるか?
- レイヤー自体のサイズが
(0, 0)になってしまいます。 - 図形は見えますが、
layer.frame.contains(point)などの当たり判定が一切効かなくなります(面積が0なので)。 - そのため、「frame で当たり判定の箱を作り、path でその中に図形を描く」 という今の実装方法が macOS アプリ開発ではベストです。
まとめ:ドラッグ移動との関係
ドラッグ移動の実装で CGAffineTransform を使う場合、実は内部的には position が変化しています。
豆知識: >
CGAffineTransformで移動させてもpath自体のデータ(座標値)は書き換わりません。「描かれた絵」はそのままで、「額縁(レイヤー)」ごと移動しているイメージです。
移動(position)
point はドラッグ中のマウスポインターの位置、イベントで受け取ってる値
start_point はクリックした地点のマウスポインターの位置、イベント受け取り
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private func modeMoveDragging(_ point: CGPoint) { guard let container = retention_container, let start = start_point else { return } let dx = point.x - start.x let dy = point.y - start.y container.position = CGPoint( x: start_container_position.x + dx, y: start_container_position.y + dy ) } |
リサイズ(bounds)
start_container_bounds はクリック地点の bounds
親レイヤーに連動して子レイヤー(実際に変形させたいレイヤー)をリサイズ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
private func modeReziseDragging(_ point: CGPoint) { guard let container = retention_container, let start = start_point, let base_bounds = start_container_bounds else { return } let base_position = start_container_position let dx = point.x - start.x let dy = point.y - start.y // サイズは最低値を保証 let new_width = max(10, base_bounds.width + dx) let new_height = max(10, base_bounds.height + dy) container.bounds = CGRect( origin: .zero, size: CGSize(width: new_width, height: new_height) ) // position は固定(左上基準なら補正する) container.position = base_position // レイヤーの型で処理分け switch container.contentLayer { case let layer as CAShapeLayer: layer.bounds = container.bounds layer.position = CGPoint( x: new_width / 2, y: new_height / 2 ) // path を再生成 let new_path = CGMutablePath() let rect = CGRect(origin: .zero, size: layer.bounds.size) let kind = layer.value(forKey: "shapeKind") as? String switch kind { case "rect", "rect_fill": new_path.addRect(rect) case "circle", "circle_fill": new_path.addEllipse(in: rect) default: break } layer.path = new_path case let layer as CATextLayer: layer.bounds = container.bounds layer.position = CGPoint( x: new_width / 2, y: new_height / 2 ) default: let layer = container.contentLayer layer.bounds = container.bounds layer.position = CGPoint( x: new_width / 2, y: new_height / 2 ) } } |
回転(setAffineTransform)
rotation_center はクリックした時のレイヤー中心地(position)
rotation_start_angle は↑から補助関数 angleBetween() で出した値
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private func modeRotateDragging(_ point: CGPoint) { guard let layer = retention_container?.contentLayer else { return } let current_angle = angleBetween( center: rotation_center, point: point ) let delta = current_angle - rotation_start_angle let t = base_trans_form.rotated(by: delta) layer.setAffineTransform(t) layer.setValue(t, forKey: "affineTransform") } |
角度計算の補助関数、センターはレイヤーの中心(.position)
中心からクリック地点の角度を求める標準の三角関数 atan2 を使ってる
|
1 2 3 |
private func angleBetween(center: CGPoint, point: CGPoint) -> CGFloat { atan2(point.y - center.y, point.x - center.x) } |
anchorPoint と position の違い
1. anchorPoint と position の役割の違い
一言でいうと、「どこを(Anchor)」 「どこに(Position)」 配置するかという関係です。
position(CGPoint):anchorPoint(CGPoint):
結果として: レイヤーは、自身の anchorPoint が親の position と重なるように配置されます。
2. コードの意図:なぜ「補正(Offset)」が必要なのか?
添付された renderContainerLayers 内の処理は、CALayer が自動で行っている計算を CGContext(手動描画)上で再現しています。
|
1 2 3 4 5 6 7 8 9 10 |
// 1. コンテナの座標(position)までキャンバスを移動させる let containerTransform = CGAffineTransform(translationX: container.position.x, y: container.position.y) ctx.concatenate(containerTransform) // 2. アンカーポイント補正 let anchorOffset = CGPoint( x: -container.bounds.width * container.anchorPoint.x, y: -container.bounds.height * container.anchorPoint.y ) ctx.translateBy(x: anchorOffset.x, y: anchorOffset.y) |
なぜこれが必要か:
- 最初の
containerTransform(positionへの移動)だけだと、キャンバスの原点(0,0)が「レイヤーを配置したい地点」になります。 - このまま描画すると、レイヤーの「左下」がその地点にきてしまいます。
- そこで、
anchorOffset(幅と高さの半分、つまり-0.5倍)だけさらにキャンバスをずらすことで、**「レイヤーの中央が position の真上に来る」**ように調整しているのです。
この処理があるおかげで、後続の回転や拡大などの変形(layer.value(forKey: "affineTransform"))が、図形の中央を中心に行われるようになります 。
3. (0.5, 0.5) に設定するメリット
ContainerLayer で self.anchorPoint = CGPoint(x: 0.5, y: 0.5) としているのは、以下の操作を直感的にするためです。
- 回転: 図形がその場(中央)でくるくる回ります。もし
(0,0)だと、左下角を中心に回ってしまいます。 - 拡大縮小: 中央から外側に向かって大きくなります。
- 配置: マウスでクリックした位置(
position)に、図形の真ん中が吸い付くように配置できます。
まとめ
positionは「キャンバス上のどこに置くか」という外側の設定。anchorPointは「図形のどこを基準にするか」という内側の設定。- ご提示のコードの計算式は、**「図形の中央を基準点として、親の
positionと一致させる」**ための正しい補正処理です。
CALayer から CGSize を取得
|
1 2 3 4 5 |
// レイヤーのサイズを取得 let size = layer.bounds.size let width = size.width // 幅 let height = size.height // 高さ |
補足:直接 width/height を取る
CGRect の拡張機能により、.size を挟まずに直接取得することも可能です(読み取り専用)。
|
1 2 |
let w = layer.bounds.width let h = layer.bounds.height |
undoManager 登録例
CanvasNSView は 自動的に UndoManager を持ちます。
Undo 登録関数
|
1 2 3 4 5 6 |
private func registerUndoForAddingStroke(_ stroke: Stroke) { undoManager?.registerUndo(withTarget: self) { target in target.removeStroke(stroke) } undoManager?.setActionName("線を描く") } |
Stroke を削除する処理(Undo 側)
|
1 2 3 4 5 6 7 8 9 10 |
private func removeStroke(_ stroke: Stroke) { guard let index = strokes.lastIndex(where: { $0 === stroke }) else { return } strokes.remove(at: index) redrawAllStrokes() undoManager?.registerUndo(withTarget: self) { target in target.addStroke(stroke) } } |
Redo 用の追加処理
|
1 2 3 4 5 6 7 8 |
private func addStroke(_ stroke: Stroke) { strokes.append(stroke) redrawAllStrokes() undoManager?.registerUndo(withTarget: self) { target in target.removeStroke(stroke) } } |
外部から undoManager を使う
|
1 |
@Environment(\.undoManager) private var undoManager |
|
1 2 3 4 5 |
Button { undoManager?.undo() } label: { Image(systemName: "arrow.uturn.backward.circle") } |
|
1 2 3 4 5 |
Button { undoManager?.redo() } label: { Image(systemName: "arrow.uturn.forward.circle") } |
未確認コード
First Responder に送る
SwiftUI から Undo を発火させる
|
1 2 3 4 5 6 7 8 |
Button { NSApp.sendAction(#selector(UndoManager.undo), to: nil, from: nil) } label: { Image(systemName: "arrow.uturn.backward.circle") } .buttonStyle(.plain) |
|
1 2 3 4 5 6 7 |
Button { NSApp.sendAction(#selector(UndoManager.redo), to: nil, from: nil) } label: { Image(systemName: "arrow.uturn.forward.circle") } |
ハマりがちなこと
座標系を混同して計算すると色々合わない
- ローカル座標系(.bouns)
- ビュー座標系 (.position, ,frame)
NSViewRepresentable
- updateNSView() ここでルートレイヤーの更新があるとその上に被せているレイヤーの位置が原点に合わせられる、対策:条件フラグ等で回避
コメント