🧑‍🤝‍🧑

【SwiftUI】UIKitの制約のようにViewに別のViewをくっつける方法を考えてみる

2024/03/08に公開

はじめに

バッジのような表現など、任意のViewに対して別のViewをくっつけたい思ったことはないでしょうか。
UIKitでは制約を追加して簡単に実現できますが、SwiftUIのVStack,HStackでは互いに影響を受けるため、一方の位置を固定することはできません。

今回はその解決策をいくつか紹介しようと思います。

方法1:GeometryReaderを使う

1つ目はViewのサイズを取得して、その分offsetをつける方法です。GeometryReaderを利用します。

GeometryReader
struct ContentView: View {
    @State private var size: CGSize?
    var body: some View {
        Color.blue.frame(width: 100, height: 100).opacity(0.2)
            .overlay(alignment: .bottomTrailing) {
                Color.blue.frame(width: 100, height: 100)
                    .offset(x: size?.width ?? 0, y: size?.height ?? 0)
                    .background {
                        GeometryReader { proxy in
                            Color.clear
                                .onChange(of: proxy.size, initial: true) { oldValue, newValue in
                                    size = newValue
                                }
                        }
                    }
            }
    }
}

Viewのサイズを使いたいと思って調べるとよく出てくる方法ですが、個人的には以下の点もあり気持ちよくかけるものではないと思っています。

  • そこそこな入れ子構造
  • Color.clearを使っている(裏技っぽい。挙動が変わる可能性)
  • サイズをプロパティで保持する必要がある

方法2:alignmentGuideを使う

2つ目のやり方としてalignmentGuideを使う方法があります。
こちらの回答を見て知りましたが、スッキリと書くことができます。
https://stackoverflow.com/a/65793521

alignmentGuide
struct ContentView: View {
    var body: some View {
        Color.blue.frame(width: 100, height: 100).opacity(0.2)
            .overlay(alignment: .bottomTrailing) {
                Color.blue.frame(width: 100, height: 100)
                    .alignmentGuide(.bottom) { dimension in
                        dimension[.bottom] - dimension.height
                    }
                    .alignmentGuide(.trailing) { dimension in
                        dimension[.trailing] - dimension.width
                    }
            }
    }
}

同じ見た目でかつ少しコードを減らすことができました👏

また、一方向にずらすだけであればもう少し削れます。

alignmentGuide
struct ContentView: View {
    var body: some View {
        Color.blue.frame(width: 100, height: 100).opacity(0.2)
            .overlay(alignment: .bottom) {
                Color.blue.frame(width: 100, height: 100)
                    .alignmentGuide(.bottom) { dimension in
                        dimension[.bottom] - dimension.height
                    }
            }
    }
}

ViewModifierで使いやすくする

方法2を使いやすくすることを考えてみます。

まずは関係のある処理をViewModifierとして切り出します。

SelfOffsetModifier(未完成)
struct SelfOffsetModifier<V: View>: ViewModifier {
    var view: (() -> V)
    func body(content: Content) -> some View {
        content
            .overlay(alignment: .bottomTrailing) {
                view()
                    .alignmentGuide(.bottom) { dimension in
                        dimension[.bottom] - dimension.height
                    }
                    .alignmentGuide(.trailing) { dimension in
                        dimension[.trailing] - dimension.width
                    }
            }
    }
}

struct ContentView: View {
    var body: some View {
        Color.blue.frame(width: 100, height: 100).opacity(0.2)
            .modifier(
                SelfOffsetModifier(view: { Color.blue.frame(width: 100, height: 100) })
            )
    }
}

次にViewModifierに「方向」と「どれだけoffsetをつけるか」を渡せるようにします。

AlignmentはVerticalAlignmentとHorizontalAlignmentを束ねた構造体なので、そこから要素を取り出して使います。

SelfOffsetModifier
struct SelfOffsetModifier<V: View>: ViewModifier {
    var alignment: Alignment
    var offsetMultiplier: CGFloat
    var view: (() -> V)
    func body(content: Content) -> some View {
        content
            .overlay(alignment: alignment) {
                view()
                    .alignmentGuide(alignment.vertical) { dimension in
                        let offset = dimension.height * offsetMultiplier
                        if alignment.vertical == .top {
                            return dimension[alignment.vertical] + offset
                        }
                        if alignment.vertical == .bottom {
                            return dimension[alignment.vertical] - offset
                        }
                        return dimension[alignment.vertical]
                    }
                    .alignmentGuide(alignment.horizontal) { dimension in
                        let offset = dimension.width * offsetMultiplier
                        if alignment.horizontal == .leading {
                            return dimension[alignment.horizontal] + offset
                        }
                        if alignment.horizontal == .trailing {
                            return dimension[alignment.horizontal] - offset
                        }
                        return dimension[alignment.horizontal]
                    }
            }
    }
}

Viewも拡張していきます。

extension View {
    func overlayWithOffset<V: View>(alignment: Alignment, offsetMultiplier: CGFloat, view: @escaping (() -> V)) -> some View {
        self.modifier(SelfOffsetModifier(alignment: alignment, offsetMultiplier: offsetMultiplier, view: view))
    }
}

最終的にこのように使えるようになります。

Color.blue.frame(width: 100, height: 100).opacity(0.2)
    .overlayWithOffset(alignment: alignment, offsetMultiplier: multiplier) {
        Color.blue.frame(width: 100, height: 100)
    }

若干粗さはありますが、9方向に関しては想定通りの動作をするようになりました👌

デモ

方法3

最後にmatchedGeometryEffectを使う方法です。
Effectとついているいる通り若干特殊なものですが、どの位置(anchor)をどの位置(anchor)へという指定が可能なため直感的に利用しやすいかと思います。

matchedGeometryEffect
struct ContentView: View {
    @Namespace var customNameSpace
    var body: some View {
        VStack {
            Color.blue.frame(width: 100, height: 100).opacity(0.2)
                .matchedGeometryEffect(id: "rectangle", in: customNameSpace, properties: .position, anchor: .bottomTrailing, isSource: true)
                .overlay {
                    Color.blue.frame(width: 100, height: 100)
                        .matchedGeometryEffect(id: "rectangle", in: customNameSpace, properties: .position, anchor: .topLeading, isSource: false)
                }
        }
    }
}

こちらも同じ見た目になります。

matchedGeometryEffectはいくつか引数を取りますが、ざっくりと説明すると@Namespaceとして定義した領域に対して同じidを持つものに関係性が生まれます。今回は位置を変更したいため、properties: .positionとしています。そして一方のViewをanchor: .bottomTrailing, isSource: true、もう一方をanchor: .topLeading, isSource: falseとすることで、isSourceがtrueのViewの右下にisSourceがfalseのViewの左上をくっつけることができます。

まとめ

いかがでしたでしょうか。今回の実装方法であれば他レイアウトに影響を及ぼさないのでなにかと便利に使えるかと思います。

カスタムAlignmentを使っても何かできそうですが、いいの思いつかなかったのでまた今度...

この記事が誰かの参考になれば幸いです。

Discussion