【SwiftUI】UIKitの制約のようにViewに別のViewをくっつける方法を考えてみる
はじめに
バッジのような表現など、任意のViewに対して別のViewをくっつけたい思ったことはないでしょうか。
UIKitでは制約を追加して簡単に実現できますが、SwiftUIのVStack,HStackでは互いに影響を受けるため、一方の位置を固定することはできません。
今回はその解決策をいくつか紹介しようと思います。
方法1:GeometryReaderを使う
1つ目はViewのサイズを取得して、その分offsetをつける方法です。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
を使う方法があります。
こちらの回答を見て知りましたが、スッキリと書くことができます。
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
}
}
}
}
同じ見た目でかつ少しコードを減らすことができました👏
また、一方向にずらすだけであればもう少し削れます。
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として切り出します。
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を束ねた構造体なので、そこから要素を取り出して使います。
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)へという指定が可能なため直感的に利用しやすいかと思います。
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