Swift・iOS

Swiftを中心に学んだことを記録に残すブログです。技術に関係ない記事もたまに書いています。

【Bitrise】設定したStackに対応するRubyやシミュレータのバージョンを確認する

 

はじめに

表題の内容に関するメモです。

 

本題

設定したStackに対応するRubyやシミュレータのバージョンを指定していないと、ビルド時に以下のようなエラーが発生する。

  • Rubyバージョンに誤りがある場合
Setup
fastlane version defined: 2.198.1, installing...
2 attempt failed
3 attempt failed
Failed to ensure fastlane version, error: Gem install failed, output: rbenv: version `3.0.2' is not installed (set by /Users/vagrant/git/.ruby-version), error: exit status 1
|                                                                              |
+---+---------------------------------------------------------------+----------+
| x | fastlane-match@0 (exit code: 1)                               | 2.89 sec |
+---+---------------------------------------------------------------+----------+
| Issue tracker: ...s://github.com/platanus/bitrise-step-fastlane-match/issues |
| Source: https://github.com/platanus/bitrise-step-fastlane-match              |
+---+---------------------------------------------------------------+----------+

 

  • シミュレータのバージョンに誤りがある場合
No simulators found that are equal to the version of specifier (15.2) and greater than or equal to the version of deployment target (0)
Ignoring 'iPhone 13 (15.2)', couldn’t find matching simulator
Couldn't find any matching simulators for '["iPhone 13 (15.2)"]' - falling back to default simulator

 

Stackのページにある以下のリンクを選択することで、設定しているStackに対応するバージョンを確認できる。

f:id:hfoasi8fje3:20220125172548p:plain

また、以下ページでは設定しているStack以外の分も確認できる。

※参考:bitrise.io/system_reports at master · bitrise-io/bitrise.io · GitHub

 

おわりに

特にRubyのバージョンについては今後もミスしてしまう可能性があると思ったので、一応記事に残しておきました。

 

参考

【Bitrise】"Issue with input: Git Url parameter not specified"の対処法

 

はじめに

表題のエラーについて記事に残します。

 

本題

エラーの詳細

BitriseのTriggersを使用してプルリクエスト時にビルドを走らせたところ、Fastlane Matchステップで表題のエラーが発生。ログを見たところ、以下のようにGitURLやAppID、TeamIDが入っていない。

Configs:
- GitURL: 
- GitBranch: master
- AppID: 
- DecryptPassword: 
- Type: development
- TeamID: 
- Options: 
- GemfilePath: ./Gemfile
- FastlaneVersion: 2.198.1
Issue with input: Git Url parameter not specified
| |
+---+---------------------------------------------------------------+----------+
| x | fastlane-match@0 (exit code: 1) | 1.80 sec |
+---+---------------------------------------------------------------+----------+
| Issue tracker: ...s://github.com/platanus/bitrise-step-fastlane-match/issues |
| Source: https://github.com/platanus/bitrise-step-fastlane-match 

 

原因

Secretsに登録していたGitURLとAppID、TeamIDの値をプルリクエスト時に公開していなかったため。

 

対処法

Workflow EditorのSecretsにある、"Expose for Pull Requests?"をオンにすることで、プルリクエスト時に対象のSecretsを公開できる。ビルド時に必要なもののみ公開するようにする。

f:id:hfoasi8fje3:20220125110446p:plain

※Secretsをプルリクエスト時に公開すると、誰でも回避策を実行してシークレットの値をログに記録できる可能性があるため、公開設定にしてよいか事前に検討してから対応する。

 

上記対応後、再度プルリクエストしてビルドを走らせてみると、以下のようになりエラーが解消する。

Configs:
- GitURL: [REDACTED]
- GitBranch: master
- AppID: [REDACTED]
- DecryptPassword: ***
- Type: development
- TeamID: [REDACTED]
- Options:
- GemfilePath: ./Gemfile
- FastlaneVersion: 2.198.1

 

おわりに

Bitriseに関する凡ミスネタがあれば、また投稿しようと思います笑

 

参考

【Bitrise】設定したTriggersが実行されない場合の対処法

 

はじめに

表題の件に関して、調べたことを記事に残します。

 

本題

まず、公式ドキュメントに従ってIncoming Webhookの設定を確認する。

参考:Triggering builds automatically - Bitrise Docs

"Code"を選択。

f:id:hfoasi8fje3:20220124182944p:plain


以下のように設定が失敗している場合はTriggersが実行されないため修正が必要。

f:id:hfoasi8fje3:20220124183129p:plain

Incoming Webhookの設定を通すために、Service credential Userが正しく設定できているか確認する。

"Team"を選択する。

f:id:hfoasi8fje3:20220124184452p:plain

Service credential User欄で接続をテストする。以下のように失敗した場合は、ホスティングサービスとの連携が正しくできていないので修正が必要。

f:id:hfoasi8fje3:20220124185251p:plain

ホスティングサービスとの連携状態を確認する。

画面右上から"Profile settings"を選択。

f:id:hfoasi8fje3:20220124185923p:plain

連携しているホスティングサービス名を選択すると、ホスティングサービス側のページに遷移する。表示された画面で、管理者権限のあるアカウントでログインしているか確認する。もし異なるアカウントでログインしていた場合は、管理者権限のあるアカウントでログインし、Bitriseと連携し直す。正しいアカウントで連携できていれば、Service credential Userの接続テストが成功し、Incoming Webhookのエラーも解消するため、Triggersが正常に動作するようになる。

f:id:hfoasi8fje3:20220124190250p:plain

 

おわりに

GitHubなどのホスティングサービスのアカウントを複数運用している状況でないと発生しないミスだと思うのでニーズはあまりなさそうな内容ですが笑、忘れないように一応記事に残しました。

 

参考

【Bitrise】エラー"Xcode Test command exit code: 65" "Xcode Test command failed, error: exit status 65"に対応する

 

はじめに

表題のエラー対処に時間がかかってしまったので記事に残します。

 

本題

発生したエラー

Bitriseでビルドを実行すると"Xcode Test for iOS"ステップで以下のエラーが発生。

ファイルが見つからない。

error: missing module map file: '/Users/vagrant/git/Pods/Target Support Files/FirebaseCore/FirebaseCore.modulemap (in target 'FirebaseCore' from project 'Pods')

error: missing module map file: '/Users/vagrant/git/Pods/Target Support Files/FirebaseMessaging/FirebaseMessaging.modulemap (in target 'FirebaseMessaging' from project 'Pods')

exit:  65

 

キャッシュの影響もあるかと思い、"Bitrise.io Cache:Pull"ステップを一旦削除して再度ビルドし直すと今度は以下のエラーが発生。

/Users/vagrant/git/Pods/GoogleUtilities/GoogleUtilities/AppDelegateSwizzler/Internal/GULSceneDelegateSwizzler_Private.h:19:9: 'GoogleUtilities/Network/Public/GoogleUtilities/GULMutableDictionary.h' file not found
#import "GoogleUtilities/Network/Public/GoogleUtilities/GULMutableDictionary.h"
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

exit:  65

今度は別のファイルが見つからない・・・。

 

対応方法

以下の手順で原因を調査し、対処法を検討する。

Xcode上でテストが通るか確認。ここで問題が発生したらBitrise起因のエラーではない。

BitriseCLIを使ってローカルでワークフローを実行。ビルドが通る場合、ローカル環境とBitrise上の環境が異なっているためにエラーとなっている可能性が高い。例えば、Stacks&Machinesで設定した環境がローカル環境と違ったり、Bitriseが対応していないツールのバージョンを使っている、そもそも.gitignoreファイルにビルドに必要なファイルが含まれているなど。

※参考:How to debug your build locally / "It works on my Mac/PC but not on bitrise.io" - How To - Bitrise Discussions

 

エラーの原因

対応方法の手順を実施したところ、ローカルでビルドが通り、BitriseCLIでもビルドが通った。.gitignoreファイルを確認したところ、エラーログに記載されていたファイルが含まれていた・・・。(なんで.gitignoreしているの・・・。)

 

おわりに

表題のエラーは様々な要因によって発生するようで、原因の特定に時間がかかってしまいました。関連記事を参考欄に記載したので、本記事の内容で解決できない場合はそちらを参考にしてもらえればと思います。

 

参考

【Combine】APIとの通信処理にCombineを取り入れる(dataTaskPublisher)

 

はじめに

"Processing URL Session Data Task Results with Combine"https://developer.apple.com/documentation/foundation/urlsession/processing_url_session_data_task_results_with_combine)にあたる内容です。APIとの通信処理にCombineを取り入れるイメージを掴むため、サンプルを作ってみました。

 

サンプルの概要

仕様に関して

TextFieldに検索ワードを入力して検索する(キーボードの「改行」を選択する)と、GitHubAPIを使ってユーザー画像とユーザー名の情報を取得し、Listに表示します。SwiftUIのプロジェクトで、アーキテクチャはMVVMです。

 

開発環境に関して

 

APIクライアントの実装に関して

APIクライアントの実装に関しては、PEAKS(ピークス)|iOSアプリ設計パターン入門のサンプルコード(iOS_architecture_samplecode/GitHub at master · peaks-cc/iOS_architecture_samplecode · GitHub)を一部引用、また、参考にしました。

 

実装方針に関して

APIクライアントの従来のコードを、以下のようにCombineに置き換えてみました。

  • URLSessionDataTask周りの実装をCombineに置き換える。(従来の処理をdataTaskPublisher、tryMap(_:)、decode(type:decoder:)、mapError(_:)を使って置き換える)
  • コールバック処理をFutureに置き換える。

また、通信処理以外では以下のようにCombineを取り入れています。

  • 検索を実行するイベントにSubjectを使う。
  • コールバック処理にFutureを使う。
  • 画像データの取得処理でdataTaskPublisherを使う。

 

全体の実装

APIクライアント

Session.swift
import Foundation
import Combine

final class Session {
    private var cancellables = Set<AnyCancellable>()
    
    func send<T: Request>(_ request: T) -> Future<T.Response, SessionError> {
        return Future() { promise in
            let url = request.baseURL.appendingPathComponent(request.path)
            
            guard var componets = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
                promise(.failure(SessionError.failedToCreateComponents(url)))
                return
            }
            componets.queryItems = request.queryParameters?.compactMap(URLQueryItem.init)
            
            guard var urlRequest = componets.url.map({ URLRequest(url: $0) }) else {
                promise(.failure(SessionError.failedToCreateURL(componets)))
                return
            }
            urlRequest.httpMethod = request.method.rawValue
            
            urlRequest.allHTTPHeaderFields = request.headerFields
            
            URLSession.shared.dataTaskPublisher(for: urlRequest)
                .tryMap() { element -> Data in
                    guard let response = element.response as? HTTPURLResponse else {
                        throw SessionError.noResponse
                    }
                    
                    guard 200 ..< 300 ~= response.statusCode else {
                        let message = try? JSONDecoder().decode(SessionError.Message.self, from: element.data)
                        throw SessionError.unacceptableStatusCode(response.statusCode, message)
                    }
                    
                    return element.data
                }
                .decode(type: T.Response.self, decoder: JSONDecoder())
                .mapError { error -> SessionError in
                    if let error = error as? DecodingError {
                        return SessionError.parserError(error.localizedDescription)
                    } else {
                        // オフラインなどのエラー
                        return SessionError.other(error.localizedDescription)
                    }
                }
                .sink(receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let error):
                        promise(.failure(error))
                    }
                },
                receiveValue: {
                    promise(.success($0))
                })
                .store(in: &self.cancellables)
        }
    }
}

enum SessionError: Error {
    case failedToCreateComponents(URL)
    case failedToCreateURL(URLComponents)
    case noResponse
    case unacceptableStatusCode(Int, Message?)
    case parserError(String)
    case other(String)
}

extension SessionError {
    struct Message: Decodable {
        let documentationURL: URL
        let message: String
        
        private enum CodingKeys: String, CodingKey {
            case documentationURL = "documentation_url"
            case message
        }
    }
}

 

User.swift
import Foundation

struct User: Codable, Identifiable {
    let id = UUID()
    let login: String
    let avatarURL: URL
    
    private enum CodingKeys: String, CodingKey {
        case login
        case avatarURL = "avatar_url"
    }
    
    init(login: String, avatarURL: URL) {
        self.login = login
        self.avatarURL = avatarURL
    }
}

 

Request.swift
import Foundation

protocol Request {
    associatedtype Response: Decodable
    
    var baseURL: URL { get }
    var method: HttpMethod { get }
    var path: String { get }
    var headerFields: [String: String] { get }
    var queryParameters: [String: String]? { get }
}

extension Request {
    var baseURL: URL {
        return URL(string: "https://api.github.com")!
    }
    
    var headerFields: [String: String] {
        return ["Accept": "application/json"]
    }
    
    var queryParameters: [String: String]? {
        return nil
    }
}

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
}

 

SearchUsersRequest.swift
import Foundation

struct SearchUsersRequest: Request {
    typealias Response = ItemsResponse<User>
    
    let method: HttpMethod = .get
    let path = "/search/users"
    
    var queryParameters: [String: String]? {
        let params: [String: String] = ["q": query]
        return params
    }
    
    let query: String
    
    init(query: String) {
        self.query = query
    }
}

 

ItemsResponse.swift
import Foundation

struct ItemsResponse<Item: Decodable>: Decodable {
    let items: [Item]
    
    init(items: [Item]) {
        self.items = items
    }
}

 

その他

SearchUserView.swift
import SwiftUI

struct SearchUserView: View {
    @ObservedObject private var viewModel: SearchUserViewModel
    
    init(viewModel: SearchUserViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            TextField("Search", text: $viewModel.text, onCommit: { viewModel.searchButtonTapped() })
                .padding([.leading, .trailing], 25)
            
            List {
                ForEach(viewModel.users) { user in
                    UserListRowView(user: user)
                }
            }
            .listStyle(InsetGroupedListStyle())
        }
    }
}

struct SearchUserView_Previews: PreviewProvider {
    static var previews: some View {
        SearchUserView(viewModel: SearchUserViewModel(searchUserModel: SearchUserModel()))
    }
}

 

UserListRowView.swift
import SwiftUI

struct UserListRowView: View {
    let user: User
    
    @Environment(\.imageCache) private var cache: ImageCache
    
    var body: some View {
        HStack(spacing: 25) {
            ImageView(url: user.avatarURL, cache: cache)
                .clipShape(Circle())
            
            Text(user.login)
        }
        .frame(height: 50)
    }
}

struct UserListRowView_Previews: PreviewProvider {
    static var previews: some View {
        UserListRowView(user: User(login: "", avatarURL: URL(string: "")!))
    }
}

 

ImageView.swift
import SwiftUI

struct ImageView: View {
    @ObservedObject private var imageloader: ImageLoader
    
    init(url: URL, cache: ImageCache? = nil) {
        imageloader = ImageLoader(url: url, cache: cache)
    }
    
    var body: some View {
        ZStack {
            if let image = imageloader.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
            }
        }
        .onAppear {
            imageloader.load()
        }
    }
}

 

SearchUserViewModel.swift
import Foundation
import Combine

final class SearchUserViewModel: ObservableObject {
    @Published var text = ""
    
    @Published var users: [User] = []
    
    private lazy var subject = PassthroughSubject<Void, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    private let searchUserModel: SearchUserModelProtocol
    
    init(searchUserModel: SearchUserModelProtocol) {
        self.searchUserModel = searchUserModel
        
        subject
            .sink(receiveValue: { _ in
                searchUserModel.fetchUser(query: self.text)
                    .receive(on: RunLoop.main)
                    .sink(receiveCompletion: { completion in
                        switch completion {
                        case .finished:
                            break
                        case .failure(let error):
                            print(error)
                        }
                    },
                    receiveValue: {
                        self.users = $0
                    })
                    .store(in: &self.cancellables)
            })
            .store(in: &self.cancellables)
    }
    
    func searchButtonTapped() {
        subject.send()
    }
}

 

SearchUserModel.swift
import Foundation
import Combine

protocol SearchUserModelProtocol {
    func fetchUser(query: String) -> Future<[User], Error>
}

final class SearchUserModel: SearchUserModelProtocol {
    private let session = Session()
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchUser(query: String) -> Future<[User], Error> {
        return Future() { promise in
            let request = SearchUsersRequest(query: query)
            self.session.send(request)
                .sink(receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let error):
                        promise(.failure(error))
                    }
                },
                receiveValue: { response in
                    promise(.success(response.items))
                })
                .store(in: &self.cancellables)
        }
    }
}

 

ImageLoader.swift
import UIKit
import Combine

final class ImageLoader: ObservableObject {
    @Published var image: UIImage?
    
    private let url: URL
    private var cache: ImageCache?
    
    private var cancellables = Set<AnyCancellable>()
    
    init(url: URL, cache: ImageCache? = nil) {
        self.url = url
        self.cache = cache
    }
    
    func load() {
        if let cache = cache?[url as AnyObject] {
            self.image = cache
        } else {
            fetchImage(url: url)
                .receive(on: RunLoop.main)
                .sink(receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let error):
                        print(error)
                    }
                }, receiveValue: {
                    self.image = $0
                    self.addCache($0)
                })
                .store(in: &cancellables)
        }
    }
    
    private func fetchImage(url: URL) -> Future<UIImage, Error> {
        return Future() { promise in
            URLSession.shared.dataTaskPublisher(for: url)
                .compactMap { UIImage(data: $0.data) }
                .sink(receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let error):
                        promise(.failure(error))
                    }
                },
                receiveValue: {
                    promise(.success($0))
                })
                .store(in: &self.cancellables)
        }
    }
    
    private func addCache(_ image: UIImage?) {
        image.map { cache?[url as AnyObject] = $0 }
    }
}

 

ImageCache.swift
import UIKit
import SwiftUI

protocol ImageCache {
    var cache: NSCache<AnyObject, UIImage> { get set }
    
    subscript(key: AnyObject) -> UIImage? { get set }
}

struct DefaultImageCache: ImageCache {
    var cache = NSCache<AnyObject, UIImage>()
    
    subscript(key: AnyObject) -> UIImage? {
        get {
            cache.object(forKey: key)
        }
        set(image) {
            cache.setObject(image!, forKey: key)
        }
    }
}

struct ImageCacheKey: EnvironmentKey {
    static let defaultValue: ImageCache = DefaultImageCache()
}

extension EnvironmentValues {
    var imageCache: ImageCache {
        get {
            self[ImageCacheKey.self]
        }
        set(image) {
            self[ImageCacheKey.self] = image
        }
    }
}

 

SampleApp.swift
import SwiftUI

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            SearchUserView(viewModel: SearchUserViewModel(searchUserModel: SearchUserModel()))
        }
    }
}

 

おわりに

通信処理に限らず、これまでCombineについて調べてきた内容を極力取り入れたサンプルになりました。ある程度Combineを導入することができたものの、手探り感が強く、まだ修正すべき点があると思っています(特にURLSessionDataTaskにおけるエラー時の処理とSubjectの使い所)。今後実践を積み重ねる中で、より理解を深めていきたいと思います。

 

参考

【Combine】Subjectsの概要

 

はじめに

Subjectsに関して調べたことや作ったサンプルを記載します。

 

開発環境

 

本題 

Subjectsとは

Publishersの一種。他のPublishersと何が違うかというと、Subjectsは自身の持つ"send(_:)"メソッドを使うことで、外部からSubjects以下に定義したOperatorsやSubscribersへ値を注入したり、イベントを発火させることができる。また、"send(completion:)"メソッドで値の公開(Publish)が正常に完了したのか、エラーが発生して終了したかどうかをSubscribersへ伝えることができる。(現状の認識に合わせて意訳してしまっているため、正確な内容は以下の参考URLを読んでください)

※参考:

 

"send(_:)"メソッドの挙動を確認する

以下のサンプルは、ボタンを選択するたびにPassthroughSubjectのsend(_:)メソッドを使って、ランダムな数字をSubscriberに公開している。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        Button("Sample") {
            viewModel.onTapped()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift
import Combine

final class ContentViewModel: ObservableObject {
    private var cancellables = Set<AnyCancellable>()
    
    private lazy var subject = PassthroughSubject<Int, Error>()
    
    enum SampleError: Error {
        case error
    }
    
    init() {
        subject
            .sink(receiveCompletion: {
                print ("completion:\($0)")
            },
            receiveValue: {
                print("\($0)")
            })
            .store(in: &cancellables)
    }
    
    func onTapped() {
        // sendの挙動チェック
        let randomNumber = Int.random(in: 1...10)
        subject.send(randomNumber)
        
        // 値の公開が正常に完了したことをSubscriberに通知する挙動を確認したい場合は、以下のコメントアウトを外す
        //        subject.send(completion: .finished)
        
        // エラーで終了する挙動を確認したい場合は、以下のコメントアウトを外す
        //        subject.send(completion: .failure(SampleError.error))
    }
}

 

おわりに

Subjectsは便利で多用してしまいそうなので、正しい使い所を理解するために、GitHubなどで実践的なコードを探して読んでみようと思います。

 

参考

【Combine】Futureの使い所

 

はじめに

Appleのドキュメント(https://developer.apple.com/documentation/combine/future)に、Futureとは「最終的に単一の値を生成し、その後終了または失敗するPublisher」と記載されていますが、これだけだと使い所がわかりません・・・。使い所に関しては以下のドキュメント(https://developer.apple.com/documentation/combine/using-combine-for-your-app-s-asynchronous-code)に記載があります。ドキュメント内の"Replace Completion-Handler Closures with Futures"を確認しながら、自分でもサンプルを作って挙動を確かめてみました。

 

開発環境

 

本題 

Futureの使い所

Futureの使い所としては、「Aの処理が完了したらBの処理を実行する」という、これまで通信処理などで利用していたコールバック処理をCombineで実装する(Combineに置き換える)場合に使います。

 

具体例

「20までカウントアップしたら完了メッセージを表示する」という仕様のサンプルを実装してみます。動きのイメージは以下。

f:id:hfoasi8fje3:20210825203041g:plain

 

コールバック処理にCombineを使わない場合は以下のようになります。

// カウントアップ処理
func startCounting(completionHandler: @escaping () -> Void) {
    Timer.publish(every: 0.1, on: .main, in: .common)
        .autoconnect()
        .receive(on: RunLoop.main)
        .sink { [weak self] _ in
            guard let self = self else { return }
            
            if self.count < self.endCount {
                self.count += 1
            } else {
                // 処理が終了した時点でcompletionHandler()を呼ぶ
                completionHandler()
            }
        }
        .store(in: &self.cancellables)
}
// カウントアップ処理の呼び出し元
startCounting() { [weak self] in
    // startCounting()の処理内でcompletionHandler()が呼ばれると以下の処理を実行する
    guard let self = self else { return }
    
    withAnimation(.easeOut(duration: 0.8)) {
        self.isCountingCompleted = true
    }
    self.cancellables.removeAll()
}

 

CombineのFutureでコールバック処理を実装すると以下のようになります。

// カウントアップ処理
func startCounting() -> Future<Void, Never> {
    return Future() { promise in
        Timer.publish(every: 0.1, on: .main, in: .common)
            .autoconnect()
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in
                guard let self = self else { return }
                
                if self.count < self.endCount {
                    self.count += 1
                } else {
                    // カウントアップが完了した時点でpromiseを実行する
                    // promiseを実行するとFutureは値を発行(公開)する
                    promise(Result.success(()))
                }
            }
            .store(in: &self.cancellables)
    }
}
// カウントアップ処理の呼び出し元
// startCounting()の戻り値であるFutureはPublishersの一種であるため、
// Operatorsで値を変換したり、Subscribersで値を受け取ることができる
startCounting()
    // startCounting()の処理内でpromiseが呼ばれると以下の処理を実行する
    .sink { _ in
        withAnimation(.easeOut(duration: 0.8)) {
            self.isCountingCompleted = true
        }
        self.cancellables.removeAll()
    }
    .store(in: &cancellables)

 

具体例で使用したサンプルの全体の実装

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Text("\(viewModel.count)")
                    .font(.title)
                    .fontWeight(.bold)
                    .padding()
                
                if viewModel.isCountingCompleted {
                    Text("Completed!")
                        .frame(width: 220, height: 50)
                        .background(Color(red: 0.8, green: 0.8, blue: 0.8))
                        .cornerRadius(110)
                        .shadow(color: Color(red: 0.85, green: 0.85, blue: 0.85), radius: 20)
                        .position(x: geometry.size.width / 2, y: 30)
                        .transition(.move(edge: .top))
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift
import SwiftUI
import Combine

final class ContentViewModel: ObservableObject {
    @Published var count = 0
    private let endCount: Int = 20
    @Published var isCountingCompleted = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // カウントアップ処理の呼び出し元
        // startCounting()の戻り値であるFutureはPublishersの一種であるため、
        // Operatorsで値を変換したり、Subscribersで値を受け取ることができる
        startCounting()
            // startCounting()の処理内でpromiseが呼ばれると以下の処理を実行する
            .sink { _ in
                withAnimation(.easeOut(duration: 0.8)) {
                    self.isCountingCompleted = true
                }
                self.cancellables.removeAll()
            }
            .store(in: &cancellables)
    }
    
    // カウントアップ処理
    func startCounting() -> Future<Void, Never> {
        return Future() { promise in
            Timer.publish(every: 0.1, on: .main, in: .common)
                .autoconnect()
                .receive(on: RunLoop.main)
                .sink { [weak self] _ in
                    guard let self = self else { return }
                    
                    if self.count < self.endCount {
                        self.count += 1
                    } else {
                        // カウントアップが完了した時点でpromiseを実行する
                        // promiseを実行するとFutureは値を発行(公開)する
                        promise(Result.success(()))
                    }
                }
                .store(in: &self.cancellables)
        }
    }
}

 

おわりに

今回読んだドキュメントに記載のある"Replace Repeatedly Invoked Closures with Subjects"の内容については別途記事にしようと思います。

 

参考

【SwiftUI】通知を画面上部からアニメーションして表示する

 

はじめに 

【SwiftUI】ポップアップ(オーバーレイ)を表示する - Swift・iOSの実装を修正して、UIPasteboardの通知やGame Centerのログイン通知、着信音オン/オフのような、画面上部からアニメーションして表示される通知を再現してみました。先に結論を言うと、再現度はいまいちです笑

 

本題

サンプルイメージ

f:id:hfoasi8fje3:20210825210401g:plain

 

開発環境

 

全体の実装

ContentView.swift

import SwiftUI

struct ContentView: View {
    @State private var selectedRating: Int = 0
    private var maximumRating = 5
    
    @State private var isOverlayPresented = false
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                ratingView
                
                if isOverlayPresented {
                    overlayView
                        .position(x: geometry.size.width / 2, y: 20)
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }
}

private extension ContentView {
    var ratingView: some View {
        HStack(spacing: 25) {
            ForEach(1 ..< maximumRating + 1) { ratingNumber in
                Image(systemName: ratingNumber > selectedRating ? "star" : "star.fill")
                    .foregroundColor(ratingNumber > selectedRating ? .gray : .yellow)
                    .onTapGesture {
                        selectedRating = ratingNumber

                        withAnimation(.easeOut(duration: 1.0)) {
                            isOverlayPresented = true
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
                            withAnimation(.easeIn(duration: 1.0)) {
                                isOverlayPresented = false
                            }
                        }
                    }
                    .font(.title)
            }
        }
    }
}

private extension ContentView {
    var overlayView: some View {
        Text("Submitted!")
            .font(.subheadline)
            .frame(width: 220, height: 50)
            .background(Color.white)
            .cornerRadius(100)
            .shadow(color: Color(red: 0.85, green: 0.85, blue: 0.85), radius: 20)
            .transition(.move(edge: .top))
    }
}

 

おわりに

遊びとしてやってみただけなので、「楽しかった」くらいの感想しかないのですが笑、せっかくなので記事に残しておきました。

 

参考

【SwiftUI】カウントアップのアニメーション

 

はじめに

【Combine】Timerの処理をCombineを使って置き換える - Swift・iOSの続きです。Stack Overflowの記事(ios - SwiftUI - Animating count text from 0 to x - Stack Overflow)を参考に、CombineのTimerを使ってカウントアップのアニメーションを実装してみました。

 

本題

サンプルイメージ

f:id:hfoasi8fje3:20210823202529g:plain

 

 

開発環境

 

全体の実装

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            Text("\(viewModel.count)")
                .font(.title)
                .fontWeight(.bold)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift
import Foundation
import Combine

final class ContentViewModel: ObservableObject {
    @Published var count = 0
    private let endCount: Int = 1000
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = Timer.publish(every: 0.02, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }

                if self.count < self.endCount {
                    self.count += 1
                } else {
                    self.cancellable?.cancel()
                }
            }
    }
}

 

おまけ:幅広い数字に対応する

サンプルでは1,000になるまで1を足し続けていますが、例えばSuicaアプリのチャージ機能のように、500円〜10,000円の範囲でアニメーションする可能性がある場合、大きい数字になればなるほどアニメーションが完了するまでの時間がかかってしまい、ユーザーを待たせてしまいます。そのため、アニメーションしたい数字に合わせて足す数字を変更して対応してみます。以下のようにViewModelの処理を変更、追加しました。

ContentViewModel.swift

import Foundation
import Combine

final class ContentViewModel: ObservableObject {
    @Published var count = 0
    
    // ここの数字を変更して動作を確認してみてください
    private let endCount: Int = 10000
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = Timer.publish(every: 0.02, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }
                
                // 追加
                let additionNumber = Int(ceil(Double(self.endCount) / 50.0))
                
                if self.count < self.endCount {
                    // 変更
                    self.count += additionNumber
                } else {
                    // 追加
                    self.count = self.endCount
                    self.cancellable?.cancel()
                }
            }
    }
}

 

※追記:アニメーション時の数字の横振れに対応する

タイマーのペースを上げると、数字が横振れしながらカウントアップするように見えてしまいます。等幅フォントに変更すると、横振れを抑えることができます。以下の記事に詳しく記載されており、参考にさせていただきました。

※参考:【SwiftUI】SFフォントを等幅フォントとして扱う方法 - おもちblog

今回のサンプルでは、ContentView.swiftのTextのフォント周りの実装を以下のように変更します。

変更前

Text("\(viewModel.count)")
    .font(.title)
    .fontWeight(.bold)

変更後

Text("\(viewModel.count)")
    .font(Font(UIFont.monospacedDigitSystemFont(ofSize: 30, weight: .bold)))

 

おわりに

「幅広い数字に対応する」で書いたことが今回記事に残したかったことなのですが、読み手からすればしょうもない内容なのでおまけにしました笑

 

参考

【Combine】Timerの処理をCombineを使って置き換える

 

はじめに

Appleのドキュメント"Replacing Foundation Timers with Timer Publishers"https://developer.apple.com/documentation/combine/replacing-foundation-timers-with-timer-publishers)に該当する内容です。Timerの処理をCombineを使って置き換えるサンプルを作ったので記事に残します。

 

開発環境

 

サンプルイメージ

f:id:hfoasi8fje3:20210822161726g:plain

 

本題

Combineを使わない場合

"scheduledTimer(withTimeInterval:repeats:block:)"を使う。ContentViewModelのstartCountingが実装箇所。

※参考:https://developer.apple.com/documentation/foundation/timer/2091889-scheduledtimer

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            Text("\(viewModel.count)")
                .font(.title)
                .fontWeight(.bold)
                .padding()
            
            Button("Start") {
                viewModel.startCounting()
            }
            .disabled(viewModel.isTimerRunning)
            
            Button("Stop") {
                viewModel.stopCounting()
            }
            .disabled(!viewModel.isTimerRunning)
            .padding()
            
            Button("Reset") {
                viewModel.resetCount()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift
import Foundation

final class ContentViewModel: ObservableObject {
    @Published var count = 0
    @Published var isTimerRunning = false
    private var timer: Timer?
    
    func startCounting() {
        isTimerRunning = true
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.count += 1
        }
    }
    
    func stopCounting() {
        isTimerRunning = false
        timer?.invalidate()
    }
    
    func resetCount() {
        count = 0
    }
}

 

Combineを使う場合

"Timer.TimerPublisher"を使って"scheduledTimer(withTimeInterval:repeats:block:)"を置き換える。

※参考:https://developer.apple.com/documentation/foundation/timer/timerpublisher

※ContentView.swiftの実装内容はCombineを使わない場合と同じです。

ContentViewModel.swift
import Foundation
import Combine

final class ContentViewModel: ObservableObject {
    @Published var count = 0    
    @Published var isTimerRunning = false
    
    private var cancellable: AnyCancellable?
    
    func startCounting() {
        isTimerRunning = true
        cancellable = Timer.publish(every: 1.0, on: .main, in: .common)
            .autoconnect()
            .sink { _ in
                self.count += 1
            }
    }
    
    func stopCounting() {
        isTimerRunning = false
        cancellable?.cancel()
    }
    
    func resetCount() {
        count = 0
    }
}

 

おわりに

今回のような簡単なサンプルではわかりにくいのですが、Combineの場合は複雑な処理にもOperatorsを使って柔軟に対応できることがメリットだと思いました。

 

参考

【SwiftUI】ポップアップ(オーバーレイ)を表示する

 

はじめに

"withAnimation(_:_:)"の挙動を確認したかったので、ポップアップを表示するサンプルを作ってみました。

 

開発環境

 

サンプルイメージ

星のアイコンを選択すると、ポップアップが表示され、1秒後に自動で消える。

※ダークモードでビルドしています。

f:id:hfoasi8fje3:20210812204744g:plain

 

実装

全体の実装

import SwiftUI

struct ContentView: View {
    @State private var selectedRating: Int = 0
    private var maximumRating = 5
    
    @State private var isOverlayPresented = false
    
    var body: some View {
        ZStack {
            HStack(spacing: 25) {
                ForEach(1 ..< maximumRating + 1) { ratingNumber in
                    Image(systemName: ratingNumber > selectedRating ? "star" : "star.fill")
                        .foregroundColor(ratingNumber > selectedRating ? .gray : .yellow)
                        .onTapGesture {
                            selectedRating = ratingNumber
                            
                            // ポップアップ表示(1秒後に自動で非表示)
                            withAnimation(.easeIn(duration: 0.2)) {
                                isOverlayPresented = true
                            }
                            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                                withAnimation(.easeOut(duration: 0.1)) {
                                    isOverlayPresented = false
                                }
                            }
                        }
                        .font(.title)
                }
            }
            
            if isOverlayPresented {
                OverlayView(isPresented: $isOverlayPresented)
            }
        }
    }
}

struct OverlayView: View {
    @Binding var isPresented: Bool
    
    var body: some View {
        Text("Submitted!")
            .frame(width: 200, height: 50)
            .foregroundColor(.black)
            .background(Color(red: 0.9, green: 0.9, blue: 0.9, opacity: 0.9))
            .cornerRadius(10)
    }
}

 

おまけ

ポップアップを使い回さない場合、以下のようにextentionを使った方が各コンポーネント(ratingViewとoverlayView)における実装の見通しがよくなるのと、どのようにレイアウトしているのかがわかりやすいかも・・・?

import SwiftUI

struct ContentView: View {
    @State private var selectedRating: Int = 0
    private var maximumRating = 5
    
    @State private var isOverlayPresented = false
    
    var body: some View {
        ZStack {
            ratingView
            
            if isOverlayPresented {
                overlayView
            }
        }
    }
}

private extension ContentView {
    var ratingView: some View {
        HStack(spacing: 25) {
            ForEach(1 ..< maximumRating + 1) { ratingNumber in
                Image(systemName: ratingNumber > selectedRating ? "star" : "star.fill")
                    .foregroundColor(ratingNumber > selectedRating ? .gray : .yellow)
                    .onTapGesture {
                        selectedRating = ratingNumber
                        
                        // ポップアップ表示(1秒後に自動で非表示)
                        withAnimation(.easeIn(duration: 0.2)) {
                            isOverlayPresented = true
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                            withAnimation(.easeOut(duration: 0.1)) {
                                isOverlayPresented = false
                            }
                        }
                    }
                    .font(.title)
            }
        }
    }
}

private extension ContentView {
    var overlayView: some View {
        Text("Submitted!")
            .frame(width: 200, height: 50)
            .foregroundColor(.black)
            .background(Color(red: 0.9, green: 0.9, blue: 0.9, opacity: 0.9))
            .cornerRadius(10)
    }
}

 

おわりに

今回のケースも含め、アニメーション周りは簡単な動きしか実装経験がないので、実践を通して理解を深めていきたいと思います。

 

参考

【Combine】Justの特徴と使い所

 

はじめに

Justの具体的な挙動や使い所について、Appleのドキュメントを読み返したり、サンプルで動きを確かめてみました。

 

開発環境

 

本題

Justの特徴

"A publisher that emits an output to each subscriber just once, and then finishes."

"In contrast with Result.Publisher, a Just publisher can’t fail with an error. And unlike Optional.Publisher, a Just publisher always produces a value."

※引用:https://developer.apple.com/documentation/combine/just

 

上記によると、Justは以下の特徴があります。

  • サブスクライバーに一度だけ値を送信(発行)し終了するパブリッシャー
  • 失敗することができない
  • 必ず値を送信(発行)する

 

具体的にはどのように値を送信しているのかprint()で確認してみます。

private let sampleString = "Hello World!"

private var cancellables: Set<AnyCancellable> = []

init() {
    // Justを使わない場合
    sampleString.publisher
        .compactMap( { String($0) } )
        .sink(receiveValue: {
            print($0)
        })
        .store(in: &cancellables)
    
    // Justを使う場合
    Just(sampleString)
        .compactMap( { String($0) } )
        .sink(receiveValue: {
            print($0)
        })
        .store(in: &cancellables)
}

f:id:hfoasi8fje3:20210806221158p:plain

Justを使わない場合は値を一つずつ送信しているのに対し、Justは値をそのまま送信し終了しています。

 

Justは呼ばれた時点(今回はinit)で一度だけ値を送信するため、以下のように実装しても、TextFieldで入力した値を監視することはできません。

ContentView.swift

import SwiftUI
import Combine

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {            
            TextField("文字を入力", text: $viewModel.textA)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(width: 300)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift

import Foundation
import Combine

final class ContentViewModel: ObservableObject {
    @Published var textA = ""
    
    private var cancellables: Set<AnyCancellable> = []
    
    init() {
        // 呼ばれた時点でのtextAの値(今回は空文字)を一度だけ送信して終了するため、
        // この実装ではTextFieldの値を監視することはできない
        Just(textA)
            .sink(receiveValue: {
                print($0)
            })
            .store(in: &cancellables)
    }
}

 

Justの使い所

 "A Just publisher is also useful when replacing a value with Publishers.Catch."

 ※引用:https://developer.apple.com/documentation/combine/just 

 

Catchを使って値を置き換える時に便利と記載があります。以下Appleのドキュメントで例を見ることができます。

※参考:https://developer.apple.com/documentation/combine/fail/catch(_:)

 

また、以下ではtryMapの処理がエラーとなった時に、tryCatch(_:)内のJustによって定義した値を送信して処理を完了するサンプルがあります。

※参考:https://developer.apple.com/documentation/combine/fail/trycatch(_:)

 

エラー時の値の置き換え時に便利というところまでは理解できました。

 

おわりに

Combineの基本についてドキュメントを探していると、「Publishersはこういうもので、Operatorsはこういうもの、Subscribersはこういうものです。次にJustですが・・・」と、Combineの概要を説明した後にJustの説明が始まることが多いのですが、なぜこの順序で突然「便利なPublishersの一つ」が紹介されるのか意図がわからず一人で勝手に混乱しました笑 「そんなにJustって重要なの?何か重要な役割があるの?」と感じてしまったのですが、自分だけか・・・笑

 

参考

【Combine】"AnyCancellable"と"Set<AnyCancellable>"の使い分け

 

はじめに

表題のテーマについて疑問に思ったので調べてみました。

※「あくまで現状の理解では」という前置きがついた内容ですのでご注意ください・・・。

 

開発環境

 

本題

様々なCombineのサンプルコードを読む中で、以下のようにAnyCancellable型のプロパティをinitで初期化するパターンと、Setに"store(in:)"を使ってAnyCancellableのインスタンスを格納するパターンがあることに気がつきました。動作としてはどちらも同じ。なぜ2パターンあるのか、どのように使い分ければいいのか疑問に思いました。

  • AnyCancellable型のプロパティをinitで初期化するパターン
private var cancellable: AnyCancellable?

init() {
    cancellable = $text
        .filter( { $0.unicodeScalars.allSatisfy( { CharacterSet.alphanumerics.contains($0) } ) } )
        .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
        .receive(on: RunLoop.main)
        .assign(to: \.filteredText, on: self)
}

 

  • Setのプロパティに"store(in:)"を使ってAnyCancellableのインスタンスを格納するパターン
private var cancellables: Set<AnyCancellable> = []

init() {
    $text
        .filter( { $0.unicodeScalars.allSatisfy( { CharacterSet.alphanumerics.contains($0) } ) } )
        .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
        .receive(on: RunLoop.main)
        .assign(to: \.filteredText, on: self)
        .store(in: &cancellables)
}

 

Combineで調べても中々該当の情報にたどりつけなかったのですが、RxSwiftのdisposeとDisposeBagの違いを調べることで解決できました。現状の理解としては、Setでは複数のAnyCancellableを一括で管理できるメリットがあるというイメージです。

参考:RxSwiftを理解する[初級編] - Qiita

 

例えば、3つのtext(textA、textB、textC)の値に対してCombineの処理を定義する場合を考えてみます。AnyCancellable型のプロパティをinitで初期化するパターンだと、以下のように3つAnyCancellable型のプロパティを定義することになります。

private var cancellableA: AnyCancellable?
private var cancellableB: AnyCancellable?
private var cancellableC: AnyCancellable?

init() {
    cancellableA = $textA
        .sink(receiveValue: { _ in
            // do something
        })
    
    cancellableB = $textB
        .sink(receiveValue: { _ in
            // do something
        })
    
    cancellableC = $textC
        .sink(receiveValue: { _ in
            // do something
        })
}

 

上記の実装をSetを使って修正してみます。こちらの方が実装がすっきりしますし、例えばプロパティtextDを追加して、Combineの処理を定義したい場合でも対応しやすいように感じます。canselメソッドを呼ぶタイミングがそれぞれ違うなどの場合を除けば、基本的にはこちらのパターンで実装してしまってよいと思いました。

private var cancellables: Set<AnyCancellable> = []

init() {
    $textA
        .sink(receiveValue: { _ in
            // do something
        })
        .store(in: &cancellables)
    
    $textB
        .sink(receiveValue: { _ in
            // do something
        })
        .store(in: &cancellables)
    
    $textC
        .sink(receiveValue: { _ in
            // do something
        })
        .store(in: &cancellables)
}

 

おわりに

RxSwiftの経験がある方からすると当たり前の知識だと思いますが、Combineからリアクティブプログラミングを学習し始めた身としては理解に時間がかかりました笑

(「本当に理解できているのか・・・?いや、できていないに違いない・・・!」という心境ですが笑)

 

CombineよりRxSwiftの方がドキュメントの数は多いですし、今回のようにCombineで探したい情報にたどり着けない場合は、似た概念やメソッドがRxSwiftにないか探してみるのもありだなと思いました。RxSwift未経験だと、似た概念やメソッドを探すのはなかなか気合いが必要という問題はあるのですが笑

 

参考

【Combine】Operatorsを使ってPublishersを制御する

 

はじめに

【Combine】Operatorsを使ってPublishersが出力する値を変換する - Swift・iOS

の続きです。Appleのドキュメント(https://developer.apple.com/documentation/combine/receiving-and-handling-events-with-combine)の"Customize Publishers with Operators"にあたる内容です。SwiftUI版のサンプルを作って動きを確かめてみたので、記事に残します。

 

開発環境

 

サンプルコード

ContentView.swift

import SwiftUI
import Combine

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            TextField("文字を入力", text: $viewModel.text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(width: 300)
                .padding()
            
            Text("抽出条件に合致した文字列を出力\n\(viewModel.filteredText)")
                .padding()
            
            Button("サンプルをリセット") {
                viewModel.text = ""
                viewModel.filteredText = ""
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift

import Foundation
import Combine

final class ContentViewModel: ObservableObject {
    @Published var text = ""
    @Published var filteredText = ""
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = $text
            .filter( { $0.unicodeScalars.allSatisfy( { CharacterSet.alphanumerics.contains($0) } ) } )
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ユーザーの入力が停止するのを待つ
            .receive(on: RunLoop.main)
            .assign(to: \.filteredText, on: self)
    }
}

※補足

Appleのドキュメントの以下実装箇所に関して

 NotificationCenter.default
    .publisher(for: NSControl.textDidChangeNotification, object: filterField)

TextFieldに入力された文字に変更があった際に通知を受け取る処理ですが、SwiftUIの場合は@PublishedなどのProperty Wrappersを使って入力文字の状態を監視することができるため、以下に変更しています。

$text

 

・"debounce(for:scheduler:options:)"によって、指定された時間が経過した後に値を公開しています。使い方のイメージですが、以下チュートリアル用のアプリが参考になりました。

※参考:GitHub - manchan/MVVM-with-Combine-Tutorial-for-iOS: MVVM with Combine Tutorial for iOS

入力した文字に合わせてAPIから天気情報を取得して表示しているようなのですが、1文字入力するごとに即時APIにリクエストするのは仕様上よくありません。そのため、debounceを使ってユーザーの入力を一定時間待った上でAPIにリクエストしています。

(該当の処理はWeeklyWeatherViewModel.swiftにあります。)

 

・処理の結果をfilteredTextに代入してUIを更新するため、"receive(on: RunLoop.main)"を実装しています。

 

SampleApp.swift(ライフサイクルが"SwiftUI App"の場合)

import SwiftUI

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: ContentViewModel())
        }
    }
}

 

おわりに

前回の内容(【Combine】Operatorsを使ってPublishersが出力する値を変換する - Swift・iOS)とテーマが同じではありますが、値を変換するだけでなく、タイミングなども制御できることを動かしながら理解することができました。サンプルを作りながら新たに理解できたこともあるので、別途記事に残そうと思います。

 

参考

【Combine】Operatorsを使ってPublishersが出力する値を変換する

 

はじめに

Apple公式ドキュメント(https://developer.apple.com/documentation/combine/receiving-and-handling-events-with-combine)の"Change the Output Type with Operators"にあたる内容です。Operatorsを使ってPublishersが出力する値を変換する処理を試しました。

 

開発環境

 

本題

以下のサンプルでは、Operatorである"compactMap(_:)"で数字の文字列をInt型にキャストしている。出力される値はInt型の1。

compactMapなので、仮にInt型にキャストできない文字列が送信されてきた場合(nilになる場合)は出力されない。

ContentView.swift

import SwiftUI
import Combine

struct ContentView: View {
    private let sampleNotification = Notification.Name("sampleNotification")
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = NotificationCenter.default.publisher(for: sampleNotification, object: nil)
            .compactMap { Int($0.userInfo!["numberString"] as! String) } 
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("finished")
                case .failure(let error):
                    print("error \(error.localizedDescription)")
                }
            },
            receiveValue: { number in
                print(number)
            })
    }
    
    var body: some View {
        Button(action: {
            NotificationCenter.default.post(
                name: sampleNotification,
                object: nil,
                userInfo: ["numberString": "1"]
            )
        }, label: {
            Text("Send notification")
        })
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

 

compactMap以外にも多くのOperatorがある。

※参考:https://developer.apple.com/documentation/combine/just-publisher-operators

例えば、compactMapからmapにサンプルコードを変更して、nilの出力を許容してみる。

変更前

.compactMap { Int($0.userInfo!["numberString"] as! String) }

変更後

.map { Int($0.userInfo!["numberString"] as! String) }

この場合にOperatorの"replaceNil(with:)"を使うと、mapの結果がnilになった場合に代わりの値を出力値として置き換えることができる。

※参考:https://developer.apple.com/documentation/combine/just/replacenil(with:)

サンプルは以下。この場合はnilが出力される代わりにInt型の2が出力される。

ContentView.swift

import SwiftUI
import Combine

struct ContentView: View {
    private let sampleNotification = Notification.Name("sampleNotification")
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = NotificationCenter.default.publisher(for: sampleNotification, object: nil)
            .map { Int($0.userInfo!["numberString"] as! String) }
            .replaceNil(with: 2)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("finished")
                case .failure(let error):
                    print("error \(error.localizedDescription)")
                }
            },
            receiveValue: { number in
                print(number)
            })
    }
    
    var body: some View {
        Button(action: {
            NotificationCenter.default.post(
                name: sampleNotification,
                object: nil,
                userInfo: ["numberString": "Non-numeric string"]
            )
        }, label: {
            Text("Send notification")
        })
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

 

おわりに

簡単なサンプルですが、Operatorsが上流から流れてきた値を変換してSubscribersに値を流しているイメージを掴むことができました。Operatorsは種類が多く、全部把握するのは骨が折れそうですが実践の中で学んでいければと思います。

 

参考