【SwiftUI】iOS15 ScrollViewのrefreshableを実装

# はじめに
iOS15以後、Listに対し、[refreshable](https://developer.apple.com/documentation/swiftui/view/refreshable(action:)/)のモディファイが利用できます。
iOS16の場合、ScrollViewに対してrefreshableのモディファイも利用できます。
今回問題になったのは、iOS15の場合ScrollViewにrefreshableのモディファイをつけても、実際は何の反応もしないこと。

# アプローチ
下記の記事には複数の方法でPull to Refreshを実現しました。
https://qiita.com/ruwatana/items/0598af785f19ed907e81
その中「Introspectを使ってSwiftUIのListからUITableViewを取り出す」に着目しました。

IntrospectとはUIViewRepresentableで内包したUIKitのViewを探し出して、動作をカスタマイズするものです。Introspectについて、いろんな紹介記事があるが、最後の参考リンクに追加します。
開発上OSSを利用できない場合は多々あります。今回はIntrospectを利用せず、その原理を利用してScrollViewのrefreshableを実装します。

# 実装
UIViewRepresentableで見えないViewをScrollViewにOverlayとして追加。そのUIViewRepresentable内、 ScrollViewに内包したUIScrollViewを探し出して、UIRefreshControlを追加するという流れです。

```swift
import SwiftUI
import UIKit

struct ScrollViewInspectorView: UIViewRepresentable {
    // 呼び出し元のViewからのハンドラを設定
    var onRefresh: () -> Void
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        view.isHidden = true
        view.isUserInteractionEnabled = false
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.async {
            guard let viewHost = uiView.superview, let superView = viewHost.superview else { return }
            let targetView = findChild(type: UIScrollView.self, in: superView)
            if let view = targetView as? UIScrollView {
                context.coordinator.scrollView = view
                // UIRefreshControlを入れる
                view.refreshControl = UIRefreshControl()
                view.refreshControl?.addTarget(
                    context.coordinator,
                    action: #selector(context.coordinator.handleRefreshControl),
                    for: .valueChanged
                )
            }
        }
    }
    
    func findChild<T: UIView>(type: T.Type, in view: UIView) -> UIView? {
        for subview in view.subviews {
            if let foundView = subview as? T {
                return foundView
            } else if let foundView = findChild(type: type, in: subview) {
                return foundView
            }
        }
        return nil
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(onRefresh: onRefresh)
    }
    
    class Coordinator: NSObject {
        var onRefresh: () -> Void
        var scrollView: UIScrollView?
        
        init(onRefresh: @escaping () -> Void, scrollView: UIScrollView? = nil) {
            self.onRefresh = onRefresh
            self.scrollView = scrollView
        }
        
        @objc func handleRefreshControl() {
            onRefresh()
            self.scrollView?.refreshControl?.endRefreshing()
        }
    }
}

```

呼び出し元：
```swift
struct ContentView: View {
    var body: some View {
        ScrollView {
            ForEach(1..<11) { number in
                Text(String(number) + "行目")
            }
        }
        .overlay {
            ScrollViewInspectorView {
                print("refresh")
            }
            .frame(width: 0, height: 0)
        }
    }
}
```

# 実際の動作
![Simulator Screen Recording - iPhone 13 mini - 2023-09-09 at 20.12.50.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2962200/048251df-fe29-890b-b682-6387cf1e7fc2.gif)

# 終わり
今回はiOS15だけのため、ScrollViewのrefreshableを実装しました。
呼び出し元のoverlayをモディファイのすることをおすすめします。

# 参考リンク
https://qiita.com/ruwatana/items/0598af785f19ed907e81
https://yanamura.hatenablog.com/entry/swiftui-introspect