Resizable sheet in SwiftUI

iOS16からSwiftUIのsheetがリサイズ可能になりました。
iOS15以前はUIKitで実装したものを取り込む必要がありましたが、iOS16からは気軽に実装できるようになりました。
簡単にまとめたので書いていきます。
といっても、sheetに表示するViewに`.presentationDetents`を追加するだけで簡単に実装できます。

---

### presentationDetents

presentationDetents(:_)の定義は以下のとおりです。
```swift
func presentationDetents(_ detents: Set<PresentationDetent>) -> some View
```
引数のdetentsはsheetでサポートされるdetent(戻り止め)のSetです。
複数のdetentを指定すると、ユーザーはsheetをドラッグしてサイズを変更することができます。
下記の例だと、.medium、.largeが指定されているので、Sheetは全画面および画面半分のサイズに可変になります。

```swift
struct ContentView: View {
  @State var showSheet = false
  
  var body: some View {
    NavigationView {
      Button("Sheet") {
        showSheet.toggle()
      }.sheet(isPresented: $showSheet, onDismiss: {
        print("Sheet dismissed")
      }) {
        Text("Hello, Swift")
        .ignoresSafeArea()
        .presentationDetents([.medium, .large])
      }
    }
  }
}
```

![Simulator Screen Recording - iPhone 14 Pro - 2023-05-21 at 18.08.35.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/d4533dd1-af80-d051-e8af-8519b407f593.gif)

一方で、presentationDetents(_:selection:)を使用することで、現在選択されているディテントをプログラムで制御できます。

```swift
func presentationDetents(
    _ detents: Set<PresentationDetent>,
    selection: Binding<PresentationDetent>
) -> some View
```

下記はボタンをタップししてsheetの現在のdetentを変更するコードになります。

```swift
var detentButton: Button<Text> {
    if selectedDetent == .medium {
      return Button("Show more") {
        selectedDetent = .large
      }
    } else {
      return Button("Show less") {
        selectedDetent = .medium
      }
    }
  }
```

```swift
var body: some View {
  Button("Sheet") {
    sheet.toggle()
  }.sheet(isPresented: $sheet, onDismiss: {
    print("Sheet dismissed")
  }) {
  VStack {
    Text("Hello, Swift")
      .padding(20)
    
    detentButton
    
    Spacer()
  }
    .presentationDetents(
      [.medium, .large],
      selection: $selectedDetent
    )
  }
}
```
![Simulator Screen Recording - iPhone 14 Pro - 2023-06-11 at 13.06.21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/f38740e0-d2c2-9a21-068c-3beaa70981fd.gif)


---

detentに指定できるものには以下が用意されています。
| タイプ | 高さ |
|:-:|:-:|
|.medium| 画面の半分  |
|.large| 画面全体|
|.height| 高さ指定 |
|.fraction| 割合 |
|custom|高さをカスタムできる|

.height, .fractionについてはそれぞれ以下のように指定します。

```ContentView.swift
.presentationDetents([
  .height(300),
  .fraction(0.3)
])
```

.customは、CustomPresentationDetentプロトコルに準拠した構造体を定義し、指定します。

```CustomDetent.swift
struct CustomDetent: CustomPresentationDetent {
  static func height(in context: Context) -> CGFloat? {
    return context.dynamicTypeSize.isAccessibilitySize ? 100 : 60
  }
}
```

```ContentView.swift
.presentationDetents([.custom(CustomDetent.self)])
```

### ドラッグインジケーターの表示

presentationDragIndicatorはシート上部にドラッグインジケーターを表示するかどうかを指定します。
```swift
func presentationDragIndicator(_ visibility: Visibility) -> some View
```
<img width="300" alt="54d649cc-c935-a3e8-49ee-5e1e6e9ef519.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/54d649cc-c935-a3e8-49ee-5e1e6e9ef519.png">

`presentationDetents`で指定したdetentsが複数の場合、デフォルトでドラッグインジケーターが表示されます。
detentsを一つしか指定しなかった場合、デフォルトではドラッグインジケーターは表示されません。

## iOS16.4以降
iOS16.4で以下のようなmodifierが追加されました。

### シートのスワイプジェスチャーの動作設定
シートを上下にスワイプした際の動作を指定します。
[presentationContentInteraction(_:)](https://developer.apple.com/documentation/swiftui/view/presentationcontentinteraction(_:)?language=_5)を使用します。

```swift
func presentationContentInteraction(PresentationContentInteraction) -> some View
```
デフォルトではシートの中がScrollViewの場合にスワイプアップするとシートの高さが変わり、シートが最大サイズに達した場合にのみScrollViewがスクロールされます。
`.scrolls`を指定した場合、ScrollViewのスクロールが優先されます。
シートサイズの変更はドラッグインジケーターを利用します。
![Simulator Screen Recording - iPhone 14 Pro - 2023-04-30 at 17.19.40.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/f81bfd1e-54d5-b144-4ede-35a8c47ed235.gif)


`.resizes`を指定した場合、シートのリサイズが優先されます。指定したdetentsの中で最大サイズの場合のみViewのスクロールが可能になります。
![Simulator Screen Recording - iPhone 14 Pro - 2023-04-30 at 17.18.27.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/8fab1acb-5b7e-60f9-fe1d-e4d550d13648.gif)

### 背後にあるViewとのインタラクション
#### シートの背後にあるViewと対話できるかどうかの制御
[presentationBackgroundInteraction(_:)](https://developer.apple.com/documentation/swiftui/view/presentationbackgroundinteraction(_:)?language=_5)を使用します。
```swift
func presentationBackgroundInteraction(_ interaction: PresentationBackgroundInteraction) -> some View
```
引数のinteractionは、シートの背後にあるViewとどのように対話するかを指定します。
| interaction | 概要 |
|:-:|:-:|
|.automatic|デフォルト|
|.disabled|インタラクション不可|
|.enabled|インタラクション可能|
|.enabled(upThrough:)|指定した高さ以下でインタラクション可能|

下記の例では、高さが`.height(120)`以下の場合にシート背後のViewとのインタラクションが可能になります。
```swift
.presentationBackgroundInteraction(.enabled(upThrough: .height(120))
```
#### インタラクティブなdismissを条件付きで防ぐ
ユーザーはジェスチャー（例えばシートを画面の一番下までスワイプダウンしたり、背面のViewをタップしたり）を使用してシートを閉じることができます。
[interactiveDismissDisabled(_:)](https://developer.apple.com/documentation/swiftui/view/interactivedismissdisabled(_:)?language=_5)を使用して条件付きでこれを防ぐことができます。
```swift
func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View
```
`isDisabled`をtrueにすることでジェスチャーで閉じることはできなくなります。
また、`isDisabled`をtrueにし、`presentationBackgroundInteraction`の引数に`.enabled(upThrough:)`を指定している場合は、背面タップした時にupThroughで指定している高さまでシートが下がります。
（スワイプダウンや背面タップすると、presentationDetentsで指定したdetentの最小の高さまで下がります。それ以上は下がりません。）


### シート上部の角丸
シート上部の角の半径を指定します。
[presentationCornerRadius(_:)](https://developer.apple.com/documentation/swiftui/view/presentationcornerradius(_:)?language=_5)を使用します

```swift
func presentationCornerRadius(_ cornerRadius: CGFloat?) -> some View
```
```swift
.presentationDragIndicator(100)
```
上記のように指定すると表示は以下のようになります。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/05b32cfd-dc63-215d-8ceb-b42747e7d4e8.png">


### シートの背景
シートの背景にスタイルを適用できます。
[presentationBackground(_:)](https://developer.apple.com/documentation/swiftui/view/presentationbackground(_:)?language=_5)もしくは[presentationBackground(alignment:content:)](https://developer.apple.com/documentation/swiftui/view/presentationbackground(alignment:content:)?language=_5)を使用します。

#### presentationBackground(_:)

```swift
func presentationBackground<S>(_ style: S) -> some View where S : ShapeStyle
```

下記の例では.thinMaterialを指定し、すりガラス表示にして背景を透過します。
```swift
  Button("Sheet") {
    showSheet.toggle()
  }.sheet(isPresented: $showSheet, onDismiss: {
    print("Sheet dismissed")
  }) {
    Text("Hello, Swift")
    .presentationDetents([.medium, .large])
    .presentationBackground(.thinMaterial)
  }
```

|||
|---|---|
|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/e7fc4e49-ed25-b875-8520-ae8a7439c4c4.png">|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/41a52c23-7f8c-de26-90dd-83e5a2f1f930.png">|

グラデーションも指定できます。
```swift
.presentationBackground(
  LinearGradient(
    colors: [.red, .yellow],
    startPoint: .top,
    endPoint: .bottom
  )
)
```
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/f8bd553f-d6f1-4e0f-23dd-a003b71e29c5.png">


#### presentationBackground(alignment:content:)

```swift
func presentationBackground<V>(alignment: Alignment = .center, @ViewBuilder content: () -> V) -> some View where V : View
```

シート自体の背景色を変えるには以下のように指定します。
```swift
.presentationBackground { Color.yellow }
```

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94477/5595d02b-b30c-4c86-5cdc-22e25c8cfbea.png">

---

以上、雑ではありますが自分用のメモとしてまとめました。