この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
この記事は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を拡大縮小して楽しんでいただければ嬉しいです。