環境
Xcode…Version 12.5 (12E262)
やりたかったことと経緯
ScrollViewにデータを表示しておいて、スワイプされたらデータ更新。
progress view(くるくる)を表示する。
新しいデータを取得完了次第progress viewを表示終了する。
というのを実現したかったのだが、そもそもSwiftUIはprogress view単体はあるもの(ProgessView
というstruct)の、「データ更新終了次第消す」機能どころか「スワイプされたらくるくるを出す」機能(androidでいうSwipeRefreshLayout、 UIKitのiOSでいうUIRefreshControl?)すらまだデフォルトでの用意はないらしい。なので自作した。
コード
1.progress viewを表示する側の準備をする①
struct TopView: View {
@StateObject var apiModel :ApiModel
var body: some View {
VStack{
if(self.apiModel.memos.count>0){
MemoList(memos: self.apiModel.memos).environmentObject(apiModel)
}else{
EmptyView()
}
}
.coordinateSpace(name: "parent")
.onAppear(){
// 表示したら読み込み開始
self.apiModel.load()
}
}
}
class ApiModel: ObservableObject {
@Published var memos : Array<String> = []
init() {
}
private let API_BASE =
"https://hogehoge"
private let API_PATH_GET_ALL_DATA="/apiNameGetAll"
public func load(memosContainer : Binding<Array<String>>? = nil) {
let url = URL(string: API_BASE+API_PATH_GET_ALL_DATA)!
URLSession.shared.dataTask(with: url) { data, response, error in
print("data: \(String(describing: data))")
do{
let allData = try JSONSerialization.jsonObject(with: data!, options: JSONSerialization.ReadingOptions.allowFragments)
print(allData) // Jsonの中身を表示
}
catch {
print(error)
}
DispatchQueue.main.async {
if(memosContainer == nil){
self.memos = try! JSONDecoder().decode(Array<String>.self, from: data!)
}else{
memosContainer!.wrappedValue = try! JSONDecoder().decode(Array<String>.self, from: data!)
}
}
}.resume()
}
}
-
TopView
: 大元のView。MemoList
はScrollViewを持ち、中でデータをViewにして表示している。(具体的コードは後述。) -
ApiModel
: http通信をして外からデータを取得してくるメソッドload(memosContainer : Binding<Array<String>>? = nil)
と取得してきたデータの入っている@Published var memos : Array<String>
を持つ。 -
load(memosContainer : Binding<Array<String>>? = nil)
は、memosContainerが渡されなかったら取得してきたデータを@Published var memos : Array<String>
に入れるが、not-nilだったら、memosContainerにデータを入れる。 - 以上のsnipetでのポイントは
.coordinateSpace(name: "parent")
。これがあとでprogress viewを出す時にきいてくる。
2.progress viewを表示するための準備②+データ更新終了時にprogress viewの表示を消す部分の実装
struct MemoList:View {
var memos : Array<String>
@EnvironmentObject var apiModel :ApiModel
init(memos : Array<String>) {
self.memos = memos
}
var body: some View{
var swipe : SwipeRefreshController?
// 中間橋渡しをしてくれるオブジェクトを作ることで、データが更新されたらクルクルを消せるようにする。
let p = Binding<Array<String>>(get : {
return self.apiModel.memos
},set : { memos in
swipe?.finishRefresh() // データ取得が終わったのでクルクルを止める
self.apiModel.memos = memos
})
swipe = SwipeRefreshController(coordinateSpaceName:"parent"){
self.apiModel.load(memosContainer: p)
}
return ScrollView(.vertical){
VStack{
swipe
ForEach(self.memos, id: \.self){ memo in
Text(memo)
}
}
}
}
}
-
MemoList
: ScrollViewにデータを表示していくView。 - また、今回の主役のprogress viewをもつViewである
SwipeRefreshController
もScrollViewの中に入れて表示する。 - SwipeRefreshControllerはinit時に
(coordinateSpaceName : String,onRefresh : @escaping (()->Void)
を引数に取る。これは引っ張られたかどうか判断するために親のcoordinateSpaceのnameを渡すためのcoordinateSpaceName : String
と、progress viewを表示した時の表示開始(正確にはスワイプされた時)コールバックのonRefresh : @escaping (()->Void)
。(コード詳細後述) - また、SwipeRefreshControllerは
finishRefresh()
を呼ぶとprogress viewを表示終了する。(コード詳細後述) - 以上のsnipetでのポイントは、まず、「progress viewに表示するための準備①」でTopViewに設定したcoordinateSpace nameの
"parent"
をSwipeRefreshController
インスタンス作成時に渡していること。 - 更に
let p = Binding<Array<String>>(get : {
return self.apiModel.memos
},set : { memos in
swipe?.finishRefresh() // データ取得が終わったのでクルクルを止める
self.apiModel.memos = memos
})
を作成し、SwipeRefreshController
のprogress view表示開始コールバックにself.apiModel.load(memosContainer: p)
を設定したこと。これにより、スワイプされてprogress viewが表示されたら、データの更新ApiModel#load(memosContainer : Binding<Array<String>>? = nil)
が呼ばれ、さらに今回はmemosContainer!=nilなのでデータ更新され次第p
にとってきたデータが入り、p#set
が呼ばれる。
- そして
p#set
の中でSwipeRefreshController#finishRefresh()
を呼ぶことで、「データの更新が終了し次第progress viewの表示を終了」を実現。さらにself.apiModel.memos = memos
を呼ぶことでTopView
やMemoList
が表示に使用しているおおもとのデータも更新する。
3.スワイプされたら感知してprogress viewを表示するViewの作成
class RefreshManager : ObservableObject {
@Published var isRefreshing = false
}
// スワイプしたら更新中のクルクルをだしてリフレッシュコールバックを呼ぶためのView
struct SwipeRefreshController: View {
init(coordinateSpaceName : String,onRefresh : @escaping (()->Void)) {
// クロージャがスコープ外でも生きる時は@escapeが必要 https://qiita.com/mishimay/items/1232dbfe8208e77ed10e#%E3%81%A9%E3%81%86%E3%81%84%E3%81%86%E3%81%A8%E3%81%8D%E3%81%AB%E5%BF%85%E8%A6%81%E3%81%8B
self.coordinateSpaceName = coordinateSpaceName
self.onRefresh = onRefresh
//isRefreshing = false
}
@ObservedObject var refreshManager = RefreshManager() // @Stateでただのbool値にしていると、外からfalseにしても反映されなかったので、ObservaleObjectクラスを作ってその中のプロパティにする。
// クルクルを消す
public func finishRefresh(){
print("finishRefresh")
self.refreshManager.isRefreshing = false
}
var coordinateSpaceName: String
var onRefresh: () -> Void // スワイプをした時のコールバック
var body: some View {
return GeometryReader { geometry in
VStack{
if geometry.frame(in: .named(coordinateSpaceName)).midY > 100 {
Spacer()
.onAppear() {
//isRefreshing = true
self.refreshManager.isRefreshing = true
onRefresh()
}
}
if (self.refreshManager.isRefreshing) {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
}
}
}
- 渡されたcoordinateSpaceNameと、GeometryReaderでスワイプされたかどうか判定して、スワイプされたら
class RefreshManager : ObservableObject
に持たせたデータ更新中フラグの@Published var isRefreshing
をたて、init
時に渡されていたスワイプ時コールバックを呼ぶ。 - また、
isRefreshing
フラグが立ったらprogress viewを表示し、おろされたら表示を終了する。
以上で、
ScrollViewにデータを表示しておいて、スワイプされたらデータ更新。
progress view(くるくる)を表示する。
新しいデータを取得完了次第progress viewを表示終了する。
を実現できました。
参考にしたもの
https://www.yururiwork.net/%E3%80%90swiftui%E3%80%91pull-to-refresh%EF%BC%88uirefreshcontrol%EF%BC%89%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B/
https://qiita.com/masa7351/items/0567969f93cc88d714ac