SwiftUIでバウンディングボックス的なものを作ってみた
この記事はSwiftUI Advent Calendar 2022の9日目の記事です。
今回はSwiftUIでバウンディングボックス的なものを作ってみようと奮闘したので思い出を記事にしておこうと思います。クリスマスまでの繋ぎに楽しんでもらえればと思います。
環境
- Xcode 14.1
- iOS 16.1
バウンディングボックスとは
画像、シェイプ、テキストを囲む長方形の枠線です。ドラッグして移動、変形、回転、拡大・縮小を行うことができます。
画像編集系アプリやお絵描きアプリの編集時に現れる枠線です。
作ったもの
隅に表示されている点をドラッグすると拡大縮小ができます。フリーフォーム、均等という拡大編集する時のタイプを選択することができ、そのタイプによって均等に拡大縮小されたり、縦だけ大きくなったり、横だけ大きくなったりします。
また、下部にあるisEditing
ボタンで編集状態を変更することができ、編集中であればバウンディングボックスが表示され、編集中でなければ表示されません。編集中はドラッグすると位置を移動することが出来ます。
SwiftUIでバウンディングボックスを作る
バウンディングボックスを構成する要素の二つを作成していきます。
- 動く破線の枠線
- 枠線の各隅に配置された編集基点
動く破線の枠線を作成
import SwiftUI struct MovingDashFramedRectangle: View { @State private var dashPhase: CGFloat = 0 @State private var timerCount: CGFloat = 0 private let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() var body: some View { Rectangle() .stroke(style: StrokeStyle(dash: [6, 6], dashPhase: dashPhase)) .onReceive(timer) { _ in timerCount = timerCount > 10 ? 0 : timerCount + 1 dashPhase = timerCount } } }
以前、記事を書いたのでこちらの詳細については省かせていただきます。
枠線の各隅に配置された編集基点を作成
編集点の印を作成
まず、見た目部分の編集点の印を作成します。
EditPointFrame
が編集点の外側の円の直径と内側の円の直径の値を持っています。
import Foundation class EditPointFrame { let outerCircleDiameter: CGFloat = 10 let innerCircleDiameter: CGFloat = 6 var outerCircleRadius: CGFloat { return outerCircleDiameter / 2 } }
EditPointMark
が編集点の印を表すViewです。
import SwiftUI struct EditPointMark: View { private let editPointFrame = EditPointFrame() var body: some View { ZStack { Circle() .foregroundColor(.white) .shadow(radius: 1) .frame(width: editPointFrame.outerCircleDiameter, height: editPointFrame.outerCircleDiameter) Circle() .foregroundColor(.accentColor) .frame(width: editPointFrame.innerCircleDiameter, height: editPointFrame.innerCircleDiameter) } } }
編集点のポジションを表すEnumを作成
EditPointPosition
編集点は、四角形の8隅に配置し、各隅によって処理が少し異なる為、編集点のポジションを示すenumを用意しました。
enum EditPointPosition: String, CaseIterable, Identifiable { case topLeft case topCenter case topRight case middleLeft case middleRight case bottomLeft case bottomCenter case bottomRight var alignment: Alignment { switch self { case .topLeft: return .topLeading case .topCenter: return .top case .topRight: return .topTrailing case .middleLeft: return .leading case .middleRight: return .trailing case .bottomLeft: return .bottomLeading case .bottomCenter: return .bottom case .bottomRight: return .bottomTrailing } } var offset: Offset { let editPointFrame = EditPointFrame() let radius = editPointFrame.outerCircleRadius switch self { case .topLeft: return Offset(x: -radius, y: -radius) case .topCenter: return Offset(x: 0, y: -radius) case .topRight: return Offset(x: radius, y: -radius) case .middleLeft: return Offset(x: -radius, y: 0) case .middleRight: return Offset(x: radius, y: 0) case .bottomLeft: return Offset(x: -radius, y: radius) case .bottomCenter: return Offset(x: 0, y: radius) case .bottomRight: return Offset(x: radius, y: radius) } } struct Offset { let x: CGFloat let y: CGFloat } }
編集点の印が四角形の枠線の中心に来るように、それぞれの位置に応じてoffset
を変化させています。
編集点をドラッグすると拡大縮小する機能
編集点の印は作成したので、ドラッグすると拡大縮小出来るようにします。
import SwiftUI struct EditPoint: View { let editingWidth: CGFloat let editingHeight: CGFloat let position: EditPointPosition let scalingAction: (_ value: EditPointScaling.Value) -> Void var dragGesture: some Gesture { DragGesture() .onChanged { value in let editPointScaling = EditPointScaling(position: position, dragGestureTranslation: value.translation) scalingAction(editPointScaling.value) } } var body: some View { ZStack { EditPointMark() .gesture(dragGesture) .offset(x: position.offset.x, y: position.offset.y) } .frame(width: editingWidth, height: editingHeight, alignment: position.alignment) } }
先ほど、作成したEditPointMark
に.gesture(dragGesture)
でドラッグジェスチャを付与しています。
ドラッグジェスチャで値を変化させた際にDragGesture.Value
を受け取ることが出来るので今回はそこからtranslation
のプロパティを使用します。このプロパティはドラッグ前と後でどのくらいの変わったという値をCGSize
で保持しています。
DragGesture() .onChanged { value in let editPointScaling = EditPointScaling(position: position, dragGestureTranslation: value.translation) scalingAction(editPointScaling.value) }
編集点のポジションによってtranslation
の値を加工するEditPointScaling
を生成して、加工した値をscalingAction
に渡しています。
translationを編集点ポジションによって加工
EditPointScaling
のコードになります。
import CoreGraphics struct EditPointScaling { /// 編集点 let position: EditPointPosition /// ドラッグジェスチャのtranslation let dragGestureTranslation: CGSize /// translationを加工した編集点の拡大縮小の値 var value: EditPointScaling.Value { return EditPointScaling.Value(scaleValue: self.scaleValue, scaleSize: self.scaleSize) } /// ドラッグされた編集点によってどのくらいサイズが変更かの値 private var scaleSize: CGSize { switch position { case .topLeft: return CGSize(width: dragGestureTranslation.width * -1, height: dragGestureTranslation.height * -1) case .topCenter: return CGSize(width: 0, height: dragGestureTranslation.height * -1) case .topRight: return CGSize(width: dragGestureTranslation.width, height: dragGestureTranslation.height * -1) case .middleLeft: return CGSize(width: dragGestureTranslation.width * -1, height: 0) case .middleRight: return CGSize(width: dragGestureTranslation.width, height: 0) case .bottomLeft: return CGSize(width: dragGestureTranslation.width * -1, height: dragGestureTranslation.height) case .bottomCenter: return CGSize(width: 0, height: dragGestureTranslation.height) case .bottomRight: return CGSize(width: dragGestureTranslation.width, height: dragGestureTranslation.height) } } /// ドラッグされた編集点によってどのくらい縦、または横にスケールしたかの値 private var scaleValue: CGFloat { switch position { case .topLeft, .topRight, .middleLeft, .middleRight, .bottomLeft, .bottomRight: return scaleSize.width case .topCenter, .bottomCenter: return scaleSize.height } } struct Value { let scaleValue: CGFloat let scaleSize: CGSize } }
これでドラッグされた編集点に応じて、拡大縮小のサイズまた縦横のスケールした値を取得出来るようになりました。
各隅に編集点を配置したViewを作成
編集点は作成出来たのでEditPointPosition
のcase分だけ編集点を配置しています。
struct EditPointsFramedRectangle: View { let width: CGFloat let height: CGFloat let scaleChangeAction: (_ value: EditPointScaling.Value) -> Void var body: some View { ZStack { ForEach(EditPointPosition.allCases) { position in EditPoint(editingWidth: width, editingHeight: height, position: position) { value in scaleChangeAction(value) } } } } }
scaleChangeAction
で各編集点に応じた値をクロージャーに渡しています。
動く枠線と重ねるとこのようになります。少しそれっぽくなってきました。
バウンディングボックスを作成
動く枠線と編集点を組み合わせてバウンディングボックスを作成します。
import SwiftUI // MARK: - Initializer extension BoundingBox { init(formType: EditFormType, isEditing: Bool, editingWidth: Binding<CGFloat>, editingHeight: Binding<CGFloat>, position: Binding<CGPoint>, @ViewBuilder content: () -> Content) { _editingWidth = editingWidth _editingHeight = editingHeight _editingPosition = position self.formType = formType self.isEditing = isEditing self.content = content() } } struct BoundingBox<Content: View>: View { @Binding var editingWidth: CGFloat @Binding var editingHeight: CGFloat @Binding var editingPosition: CGPoint let formType: EditFormType let isEditing: Bool let content: Content private let minScalingWidth: CGFloat = 10 private let minScalingHeight: CGFloat = 10 private var dragGesture: some Gesture { DragGesture().onChanged { value in editingPosition = value.location } } var body: some View { ZStack { if isEditing { content .overlay { MovingDashFramedRectangle() EditPointsFramedRectangle(width: editingWidth, height: editingHeight) { value in switch formType { case .freeForm: guard editingWidth + value.scaleSize.width > minScalingWidth, editingHeight + value.scaleSize.height > minScalingHeight else { return } editingWidth += value.scaleSize.width editingHeight += value.scaleSize.height case .uniform: guard editingWidth + value.scaleValue > minScalingWidth, editingHeight + value.scaleValue > minScalingHeight else { return } editingWidth += value.scaleValue editingHeight += value.scaleValue } } } .frame(width: editingWidth, height: editingHeight) .position(editingPosition) .gesture(dragGesture) } else { content .frame(width: editingWidth, height: editingHeight) .position(editingPosition) } } } }
プロパティ
- editingWidth: CGFloat
- 編集しているwidthの値
- editingHeight: CGFloat
- 編集しているheightの値
- editingPosition: CGPoint
- 編集している位置の値
- formType: EditFormType
- フリーフォームか均等での拡大縮小かの種別
- isEditing: Bool
- 編集中かどうか
- content: Content
- 編集中のコンテンツ
- minScalingWidth: CGFloat
- 縮小時のwidthの下限値
- minScalingHeight: CGFloat
- 縮小時のheightの下限値
今回は拡大縮小のwidthとheightの下限値を10
にしました。
EditFormType
編集方法に今回はフリーフォームと均等を用意しました。
import Foundation enum EditFormType: String, CaseIterable, Identifiable { case freeForm = "Free form" case uniform = "Uniform" var id: String { rawValue } }
ドラッグジェスチャ
バウンディングボックス自体のジェスチャには、ポジションを変更する為のジェスチャを設定しています。DragGesture.Value
のlocation
ではドラッグされている現在の位置を取得出来るのでその値をeditingPosition
に渡しています。
private var dragGesture: some Gesture { DragGesture().onChanged { value in editingPosition = value.location } }
body
編集中であれば、動く枠線と編集点が表示されたcontentを表示しています。
content .overlay { MovingDashFramedRectangle() EditPointsFramedRectangle(width: editingWidth, height: editingHeight) { value in switch formType { case .freeForm: guard editingWidth + value.scaleSize.width > minScalingWidth, editingHeight + value.scaleSize.height > minScalingHeight else { return } editingWidth += value.scaleSize.width editingHeight += value.scaleSize.height case .uniform: guard editingWidth + value.scaleValue > minScalingWidth, editingHeight + value.scaleValue > minScalingHeight else { return } editingWidth += value.scaleValue editingHeight += value.scaleValue } } } .frame(width: editingWidth, height: editingHeight) .position(editingPosition) .gesture(dragGesture)
また、フリーフォームか均等かのEditFormType
によって、scaleValue
で変更するかscaleSize
の値で変更するかを分岐しています。
編集中でない場合は、frame
とposition
をセットしたcontent
のみを表示しています。
content .frame(width: editingWidth, height: editingHeight) .position(editingPosition)
Extensionの作成
SwiftUIっぽく使用する為にViewのエクステンションを作成しました。
import SwiftUI extension View { func boundingBox(formType: EditFormType, isEditing: Bool, editingWidth: Binding<CGFloat>, editingHeight: Binding<CGFloat>, position: Binding<CGPoint>) -> some View { self.modifier(BondingBoxModifier(formType: formType, isEditing: isEditing, width: editingWidth, height: editingHeight, position: position)) } } struct BondingBoxModifier: ViewModifier { let formType: EditFormType let isEditing: Bool @Binding var width: CGFloat @Binding var height: CGFloat @Binding var position: CGPoint func body(content: Content) -> some View { BoundingBox(formType: formType, isEditing: isEditing, editingWidth: $width, editingHeight: $height, position: $position) { content } } }
使い方
あとはバウンディングボックスを表示したいViewに.boundingBox
をつけてあげると使用できます。
Image(systemName: "nose") .resizable() .boundingBox(formType: formType, isEditing: isEditing, editingWidth: $width, editingHeight: $height, position: $location)
おわりに
コードはGitHubにあげています。
バウンディングボックスに回転の要素がまだ実装できていないことに気づいたのでまた今度追加しておきたいと思います。
見ていただきありがとうございました。クリスマスまでの時間潰しにViewを拡大縮小して楽しんでいただければ嬉しいです。