---
title: Refreshable API を TCA で使う
tags:  #swift #swiftui #the composable architecture #tca  
author: [アイカワ](https://convert.docswell.com/user/kalupas226)
site: [Docswell](https://www.docswell.com/)
thumbnail: https://bcdn.docswell.com/page/V4EQVX4RJP.jpg?width=480
description: Refreshable API を TCA で使う by アイカワ
published: November 13, 21
canonical: https://convert.docswell.com/s/kalupas226/KEER8K-2021-11-13-123255
---
# Page. 1

![Page Image](https://bcdn.docswell.com/page/V4EQVX4RJP.jpg)

を
Refreshable API
TCA
iOSアプリ開発のためのFunctional Architecture情報共有会5
で使う


# Page. 2

![Page Image](https://bcdn.docswell.com/page/DKJ4M6QQE1.jpg)

とは
Refreshable API
iOS 15 から利用できるようになった View Modifier
SwiftUI なら簡単に Pull to Refresh を実現できる
`refreshable` を利用するだけ
これが取る closure は async な処理を要求する
List(mailbox.conversations) {
ConversationCell($0)
}
.refreshable {
await mailbox.fetch()
}
2


# Page. 3

![Page Image](https://bcdn.docswell.com/page/PLE18VPL7G.jpg)

ではどのように
を利用できるか
TCA
refreshable
TCA では v0.23.0 から `ViewStore.send(_:while:)` というものが導入されている(現在時点では Beta ）
これを refreshable 内で利用すると TCA で refreshable が上手く扱える
「Async Refreshable: Composable Architecture」という Point-Free 内のエピソードをもとに、
TCA でどのように refreshable が扱えるか見ていこうと思います
3


# Page. 4

![Page Image](https://bcdn.docswell.com/page/8GEWZL2QE2.jpg)

Refreshable with TCA
を理解するために利用する例
4


# Page. 5

![Page Image](https://bcdn.docswell.com/page/W47Z1MD3J3.jpg)

API Client
的部分
struct FactClient {
var fetch: (Int) -&gt; Effect&lt;String, Error&gt;
struct Error: Swift.Error, Equatable {}
}
extension FactClient {
static let live = Self(
fetch: { number in
URLSession.shared.dataTaskPublisher(
for: URL(string: &quot;http://numbersapi.com/\(number)/trivia&quot;)!
)
.map { data, _ in String(decoding: data, as: UTF8.self) }
.catch { _ in
Just(&quot;\(number) is a good number Brent&quot;)
.delay(for: 1, scheduler: DispatchQueue.main)
}
.setFailureType(to: Error.self)
.eraseToEffect()
}
)
}
5


# Page. 6

![Page Image](https://bcdn.docswell.com/page/KYJ6LN1ZEV.jpg)

State, Action, Environment
struct PullToRefreshState: Equtable {
var count = 0
var fact: String?
}
enum PullToRefreshAction: Equatable {
case cancelButtonTapped
case decrementButtonTapped
case incrementButtonTapped
case refresh
case factResponse(Result&lt;String, FactClient.Error&gt;)
}
struct PullToRefreshEnvironment {
var fact: FactClient
var mainQueue: AnySchedulerOf&lt;DispatchQueue&gt;
}
6


# Page. 7

![Page Image](https://bcdn.docswell.com/page/5GJ5154974.jpg)

Reducer
refreshReducer = Reducer&lt;PullToRefreshState, PullToRefreshAction, PullToRefreshEnvironment&gt;
{ state, action, environment in
struct CancelId: Hashable {}
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case let .factResponse(.success(fact)):
state.fact = fact
return .none
case .factResponse(.failure):
return .none // TODO:
case .refresh:
return environment.fact.fetch(state.count)
.receive(on: environment.mainQueue)
.catchToEffect(PullToRefreshAction.factResponse)
.cancellable(id: CancelId())
case .cancelButtonTapped:
return .cancel(id: CancelId())
}
}
エラーハンドリング
7


# Page. 8

![Page Image](https://bcdn.docswell.com/page/4LE31NG275.jpg)

View(store
宣言部分)
struct PullToRefreshView: View {
let store: Store&lt;PullToRefreshState, PullToRefreshAction&gt;
}
var body: some View {
// ...
}
8


# Page. 9

![Page Image](https://bcdn.docswell.com/page/V8EDXW16JG.jpg)

View(body)
var body: some View {
WithViewStore(self.store) { viewStore in
List {
HStack {
Button(&quot;-&quot;) { viewStore.send(.decrementButtonTapped) }
Text(&quot;\(viewStore.count)&quot;)
Button(&quot;+&quot;) { viewStore.send(.incrementButtonTapped) }
}
.buttonStyle(.plain)
if let fact = viewStore.fact {
Text(fact)
}
if viewStore.isLoading {
Button(&quot;Cancel&quot;) {
viewStore.send(.cancelButtonTapped)
}
}
}
.refreshable {
viewStore.send(.refresh)
}
9


# Page. 10

![Page Image](https://bcdn.docswell.com/page/4V7PP95Z78.jpg)

Preview
struct PullToRefreshView_Previews: PreviewProvider {
static var previews: some View {
PullToRefreshView(
store: .init(
initialState: .init(),
reducer: pullToRefreshReducer,
environment: .init(
fact: .live,
mainQueue: .main
)
)
)
}
}
10


# Page. 11

![Page Image](https://bcdn.docswell.com/page/L2JV2MPMJQ.jpg)

実行してみる
11


# Page. 12

![Page Image](https://bcdn.docswell.com/page/Z5EGRGPXJL.jpg)

動作的には問題なさそうに見える？


# Page. 13

![Page Image](https://bcdn.docswell.com/page/V4JQVX257P.jpg)

コードを少し変更してみる
case .refresh:
return environment.fact.fetch(state.count)
.delay(for: 2, scheduler: environment.mainQueue)
.catchToEffect(PullToRefreshAction.factResponse)
.cancellable(id: CancelId())
13


# Page. 14

![Page Image](https://bcdn.docswell.com/page/DK74M6LVJ1.jpg)

通信が完了してないのに indicator が消えてしまう
14


# Page. 15

![Page Image](https://bcdn.docswell.com/page/PLJ18VL4EG.jpg)

何が問題なのか
は closure に async な処理を要求する
提供された非同期な処理が実行されている限り loading indicator が留まるというものになっている
現在実装している `viewStore.send(.refresh)` は async ではない同期的な処理
TCA でこの問題を解決するためには少し工夫する必要がある
`refreshable` View Modifier
15


# Page. 16

![Page Image](https://bcdn.docswell.com/page/8GJWZLVZJ2.jpg)

State
に isLoading を導入
struct PullToRefreshState: Equatable {
var count = 0
var fact: String?
var isLoading = false
}
16


# Page. 17

![Page Image](https://bcdn.docswell.com/page/W4EZ1M4L73.jpg)

isLoading
を reducer で操作
switch action {
case let .factResponse(.success(fact)):
state.fact = fact
state.isLoading = false
return .none
case .factResponse(.failure):
state.isLoading = false
return .none
case .refresh:
state.isLoading = true
// ...
case .cancelButtonTapped:
state.isLoading = false
return .cancel(id: CancelId())
}
17


# Page. 18

![Page Image](https://bcdn.docswell.com/page/KY76LN3MJV.jpg)

あとは async 的に利用できる send があると良さそう
こんな感じ
//
.refreshable {
await viewStore.send(.refresh, while: \.isLoading)
}
18


# Page. 19

![Page Image](https://bcdn.docswell.com/page/5G7515LQE4.jpg)

async
な send の signature はこのような形
extension ViewStore {
func send(
_ action: Action,
`while`: (State) -&gt; Bool
) async {
//
}
}
実装
19


# Page. 20

![Page Image](https://bcdn.docswell.com/page/V9J21NLW7R.jpg)

実装を考えてみる
func send(
_ action: Action,
`while`: (State) -&gt; Bool
) async {
//
Action
self.send(action)
// ViewStore
state
self.publisher
.filter { !`while`($0) } // `while`
}
まずは何よりも
を発火させる必要がある
には全ての
の変化が流れてくる publisher があるため、それを監視する
は escaping でないためエラーが発生する
20


# Page. 21

![Page Image](https://bcdn.docswell.com/page/5DEYZPQ97M.jpg)

実装を考えてみる2
func send(
_ action: Action,
`while` isInFlight: @escaping (State) -&gt; Bool // escaping + internal argument
) async {
self.send(action)
self.publisher
.filter { !isInFlight($0) }
.prefix(1) // isLoading
.sink { _ in
//
}
}
の変化の監視は最初のものだけ判別できれば良い
実装
ここで生じる問題点
`sink` は `cancellable` を返すがどうする？
最終的には async な task を構築する必要があるがどうする？
21


# Page. 22

![Page Image](https://bcdn.docswell.com/page/2VJN35KDJ8.jpg)

にするための
publisher -&gt; async
Bridge
Swift はそのための Bridge となる function を用意してくれている
`withUnsafeContinuation`
non-async/await
なコードを async/await なコードに変えられる
// signature
withUnsafeContinuation(&lt;#(UnsafeContinuation&lt;T, Never&gt;) -&gt; Void#&gt;)
使い方
//
let number = await WithUnsafeContinutation { continuation in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
continuation.resume(returning: 42)
}
}
22


# Page. 23

![Page Image](https://bcdn.docswell.com/page/GYE99YV8E3.jpg)

`withUnsafeContinuation`
を `send` で利用する
func send(
_ action: Action,
`while` isInFlight: @escaping (State) -&gt; Bool
) async {
self.send(action)
await withUnsafeContinuation { continuation in
self.publisher
.filter { !isInFlight($0) }
.prefix(1)
.sink { _ in
continuation.resume()
}
}
}
23


# Page. 24

![Page Image](https://bcdn.docswell.com/page/5GE896PZ7D.jpg)

`cancellable`
の取り扱い方
func send(
_ action: Action,
`while` isInFlight: @escaping (State) -&gt; Bool
) async {
self.send(action)
}
var cancellable: Cancellable?
await withUnsafeContinuation { (continuation: UnsafeContinuation&lt;Void, Never&gt;) in //
cancellable = self.publisher
.filter { !isInFlight($0) }
.prefix(1)
.sink { _ in
continuation.resume()
_ = cancellable // strongly capture
}
}
型推論ができなくなるため型を明示
24


# Page. 25

![Page Image](https://bcdn.docswell.com/page/MLELW911JR.jpg)

現在時点の Beta ディレクトリにある実装方法
func send(
_ action: Action,
while predicate: @escaping (State) -&gt; Bool
) async {
self.send(action)
await self.suspend(while: predicate)
}
func suspend(while predicate: @escaping (State) -&gt; Bool) async {
_ = await self.publisher
.values // AsyncPublisher&lt;Self&gt;
.first(where: { !predicate($0) }) // AnyCancellable
}
を返却しないため、そのための対処が必要ない
25


# Page. 26

![Page Image](https://bcdn.docswell.com/page/54JM9ZW5EW.jpg)

コンパイルが通るようになる
.refreshable {
await viewStore.send(.refresh, while: \.isLoading)
}
26


# Page. 27

![Page Image](https://bcdn.docswell.com/page/ZPJRG6YZE9.jpg)

cancel
時の animation がない問題がある
27


# Page. 28

![Page Image](https://bcdn.docswell.com/page/NPEXXMG1EX.jpg)

combine-schedulers
の animation 機能を使って解決
case .refresh:
state.isLoading = true
state.fact = nil
return environment.fact.fetch(state.count)
.delay(for: 2, scheduler: environment.mainQueue.animation())
// ...
// ...
Button(&quot;Cancel&quot;) {
viewStore.send(.cancelButtonTapped, animation: .default)
}
28


# Page. 29

![Page Image](https://bcdn.docswell.com/page/63EKWG4MED.jpg)

無事 cancel 時の animation が行われるようになる
29


# Page. 30

![Page Image](https://bcdn.docswell.com/page/4L731NL2J5.jpg)

まとめ
の `refreshable` View Modifier は簡単に Pull To Refresh を表現できる
`refreshable` は async な処理を要求するため、TCA で利用するためには工夫が必要
現在時点では Beta だが、TCA にはそのための `viewStore.send(_:while:)` が用意されている
発表では紹介しなかったが、TCA を利用すると非常に網羅的なテストが可能となる
網羅的なテストができることが TCA の売り
例えば State を追加したりしたら、その State の変化を検証しないとテストは失敗する
発生しうる Action も `receive` 等によって網羅する必要がある
素の SwiftUI だと以下のような部分でテストが厳しくなると述べられていた
詳しくは Point-Free の「Async Refreshable: SwiftUI」を参照して頂ければと思います🙏
API リクエストをキャンセルする際のフローがテストできない(する方法がわからない)
Xcode Beta 版のバグか、Swift の custom executors を使う必要があるのかはっきりしていないらし
い
async な処理中の `isLoading` の変化をテストするために、テスト内で Sleep を行う必要がある
SwiftUI
30


