SwiftUIでバウンディングボックス的なものを作ってみた

2022.12.09

この記事はSwiftUI Advent Calendar 2022の9日目の記事です。

今回はSwiftUIでバウンディングボックス的なものを作ってみようと奮闘したので思い出を記事にしておこうと思います。クリスマスまでの繋ぎに楽しんでもらえればと思います。

環境

  • Xcode 14.1
  • iOS 16.1

バウンディングボックスとは

画像、シェイプ、テキストを囲む長方形の枠線です。ドラッグして移動、変形、回転、拡大・縮小を行うことができます。

引用: Adobe バウンディングボックス

画像編集系アプリやお絵描きアプリの編集時に現れる枠線です。

作ったもの

swiftui-bounding-box-demo

隅に表示されている点をドラッグすると拡大縮小ができます。フリーフォーム、均等という拡大編集する時のタイプを選択することができ、そのタイプによって均等に拡大縮小されたり、縦だけ大きくなったり、横だけ大きくなったりします。

また、下部にある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
            }
    }
}

以前、記事を書いたのでこちらの詳細については省かせていただきます。

【SwiftUI】動く破線の枠線で囲まれたViewを作ってみた

枠線の各隅に配置された編集基点を作成

編集点の印を作成

まず、見た目部分の編集点の印を作成します。

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.Valuelocationではドラッグされている現在の位置を取得出来るのでその値を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の値で変更するかを分岐しています。

編集中でない場合は、framepositionをセットした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を拡大縮小して楽しんでいただければ嬉しいです。

参考