SwiftUI で UICalendarView に小さいサイズのframeを指定すると要素が溢れる問題

# 環境

- XCode 16.1
- Swift 5.9

# 遭遇した問題

SwiftUI で`UICalendarView`を表示する際に、高さを`frame(height:)`で 200pt にした際に、以下のように画面からはみ出るということが起きました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/3496f618-8164-3c90-8021-134186887c89.png)

期待しているレイアウトは以下の様な感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/80ce5cc3-4704-cedb-98c4-9bc849b1823e.png)

見比べてみるとわかるとおり、カレンダーの上部がテキストの領域に被ってしまっており見えない様になってしまっています。

問題のコードは以下です。

```swift
#Preview("画面がはみ出るサンプル") {
    VStack {
        Text("Dummy Text")
        ScrollView {
            CalendarView(initialDate: .init(),
                         interval: .init(start: .now,
                                         duration: 3000000))
            .frame(maxHeight: 200)
        }
    }
}

struct CalendarView: UIViewRepresentable {
    
    @Environment(\.locale) private var locale
    
    private let initialDate: DateComponents
    private let interval: DateInterval
    
    init(initialDate: DateComponents,
         interval: DateInterval) {
        
        self.initialDate = initialDate
        self.interval = interval
    }
    
    func makeUIView(context: Context) -> UICalendarView {
        
        return UICalendarView()
    }

    func updateUIView(_ uiView: UICalendarView, context: Context) {
        
        uiView.visibleDateComponents = initialDate
        uiView.availableDateRange = interval
        uiView.locale = locale
    }
}
```

# 問題の原因を考える
問題のコードから高さの指定を削除すると、問題が解消されたことから指定したサイズと`CalendarView`自体のサイズと競合して発生した問題推測できます。

```diff_swift
#Preview("画面がはみ出るサンプル") {
    VStack {
        Text("Dummy Text")
        ScrollView {
            CalendarView(initialDate: .init(),
                         interval: .init(start: .now,
                                         duration: 3000000))
-            .frame(maxHeight: 300)
        }
    }
}
```

`CalendarView`の上に同じ高さで指定した`Rectangle`を配置してみると`CalendarView`が指定したサイズである200ptを超えたサイズで表示されてしまっていることがわかります。

```swift
#Preview("画面がはみ出るサンプル") {
    VStack {
        Text("Dummy Text")
        ScrollView {
            ZStack {
                CalendarView(initialDate: .init(),
                             interval: .init(start: .now,
                                             duration: 3000000))
                .background(.red.opacity(0.3))
                Rectangle()
                    .frame(width: 30, height: 200)
            }
            .frame(maxHeight: 200)
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/a4443325-7a83-8a52-20f9-5b64c67286e4.png)

今回の事象で分かる通り、`UICalendarView`のサイズとSwiftUIの高さ指定が競合した場合、`UICalendarView`のサイズが優先されるため、SwiftUIを指定しない様にすれば事象は解決します。


# `UICalendarView`の高さをSwiftUI側で指定した値を優先して反映してほしい場合
以下記事で解説されていますが、`UICalendarView`に対して`setContentCompressionResistancePriority`というAPIを用いれば可能です。

https://qiita.com/jyu0414/items/7a4a868eb62860be02be

APIのドキュメントは以下です。

>Sets the priority with which a view resists being made smaller than its intrinsic size.

日本語訳

>ビューが本来のサイズよりも小さくなるのを防ぐ優先度を設定します。

https://developer.apple.com/documentation/uikit/uiview/setcontentcompressionresistancepriority(_:for:)

ドキュメントの記載にある通り、`setContentCompressionResistancePriority`ではViewが小さくなるのを防ぐ優先度を設定するもので、今回の場合は縦方向に小さくなるのを防ぐ優先度が高いため起きた事象と思われるので、この優先度を下げることでうまくいきそうです。

```diff_swift
Preview("画面がはみ出るサンプル") {
    VStack {
        Text("Dummy Text")
        ScrollView {
            ZStack {
                CalendarView(initialDate: .init(),
                             interval: .init(start: .now,
                                             duration: 3000000))
                .background(.red.opacity(0.3))
                Rectangle()
                    .frame(width: 30, height: 200)
            }
            .frame(maxHeight: 200)
        }
    }
}

struct CalendarView: UIViewRepresentable {
    
    @Environment(\.locale) private var locale
    
    private let initialDate: DateComponents
    private let interval: DateInterval
    
    init(initialDate: DateComponents,
         interval: DateInterval) {
        
        self.initialDate = initialDate
        self.interval = interval
    }
    
    func makeUIView(context: Context) -> UICalendarView {
        
        return UICalendarView()
    }

    func updateUIView(_ uiView: UICalendarView, context: Context) {
        
        uiView.visibleDateComponents = initialDate
        uiView.availableDateRange = interval
        uiView.locale = locale
+       uiView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/e24d87bc-f35a-1532-7ada-e4a1e76721a7.png)

キャプチャを見て分かるように、`CalendarView`はSwiftUIの指定通り高さ200ptで表示されるようになり上部が見切れてしまうこともなくなりましたが、今度は下部が見切れてしまいました。

明確に記載されている記事が見つからなかったので事象からの推測になりますが、`UICalendarView`を全て表示するには最低限必要な高さがあるため、`UICalendarView`のCompressionPriority(小さくなることを防ぐ優先度)よりも高い優先度で小さい高さを指定された場合には見切れて表示するしか無くなるのかなと思いました。

カレンダーが全て表示されないのは、望むところではないと思うので、高さの指定はしない方が良いのかなというのが、触ってみての現場の私の結論です。

# これって`UICalendarView`以外でも起きるものなの？
今回の原因を考えると、`UICalendarView`というよりは`UIView`のCompressionPriorityの概念によるものに依存していそうなので`UIView`であれば他のコンポーネントでも起きそうだなと思いました。
ですので、試しに`UILabel`で同じことが起きるか見てみます。

```swift
#Preview("画面がはみ出るサンプル") {
    VStack {
        ScrollView {
            ZStack {
                SampleLabel(text: "O")
                    .background(.red.opacity(0.3))
                Color.blue
                    .frame(width: 30, height: 200)
            }
            .frame(maxHeight: 200)
        }
    }
}

struct SampleLabel: UIViewRepresentable {
    
    let text: String
    
    func makeUIView(context: Context) -> UILabel {
        
        return UILabel()
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {
        
        uiView.font = .systemFont(ofSize: 400, weight: .bold)
        uiView.text = text
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/78e47ff9-58a0-d905-014b-fb82ac1356ac.png)

`SampleLabel`の領域が、SwiftUIで指定した高さを超えて画面外へはみ出ていることがわかります。

高さの指定を削除すると、画面外へはみ出ることはなくなります。

```diff_swift
#Preview("画面がはみ出るサンプル") {
    VStack {
        ScrollView {
            ZStack {
                SampleLabel(text: "O")
                    .background(.red.opacity(0.3))
                Color.blue
                    .frame(width: 30, height: 200)
            }
-              .frame(maxHeight: 200)
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/39674004-8348-95db-ef81-a610ae48bfae.png)

次に、縦方向のCompressionPriorityを下げてみて、再度SwiftUI側で高さを指定した時に、SwiftUIの高さが優先され、UIView側のコンテンツが見切れるとなると今回の仮説(`UICalendarView`だけでなく`UIView`であれば今回の事象は発生する)は正しそうです。

```diff_swift
#Preview("画面がはみ出るサンプル") {
    VStack {
        ScrollView {
            ZStack {
                SampleLabel(text: "O")
                    .background(.red.opacity(0.3))
                Color.blue
                    .frame(width: 30, height: 200)
            }
+           .frame(maxHeight: 200)
        }
    }
}

struct SampleLabel: UIViewRepresentable {
    
    let text: String
    
    func makeUIView(context: Context) -> UILabel {
        
        return UILabel()
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {
        
        uiView.font = .systemFont(ofSize: 400, weight: .bold)
        uiView.text = text
+       uiView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/45110175-1315-b6f9-b9fb-6339570a236a.png)

こちらも想定通り縦方向のCompressionPriorityを下げることでSwiftUIの高さ指定が反映されたので、この事象は`UIView`であれば起こりうることがわかりました。

# おわり
SwiftUIでUIViewを扱うときは、UIView側でframeがどう設定されているかは意識しないと意図しないレイアウト崩れにつながりそうということがわかりました。
今回の私の遭遇したケースでは、カレンダーを見切れることはしたくなく、横幅も変えたくなかったので`setContentCompressionResistancePriority`を使わずSwiftUI側の高さ指定を消すことで対応しましたが、その時々の状況でどういうふうに対応するかも変わってくると思うので、何ができるかを押さえた上で意思決定していければと思います。

今回の内容で他にもできることがあるなどありましたら、ご教授いただけると幸いです。