20211128のSwiftに関する記事は3件です。

【SwiftUI × Combine】ログイン画面を作ってみる MVVM編

前提 MVVMの理解のためにログイン画面を作ります。 UnitTest, UITestも簡単に書いてみます。 環境 macOS 11.4 xcode13.1 swift5 できたもの 参考 MVVM以外にも色々なアーキテクチャが実装例付きで説明されていてわかりやすい書籍です。 MVVMの実装例の記事は色々ありますがこれが一番わかりやすかったです。 実装 M: Model Modelの説明として「iOSアプリ設計パターン入門」にこう記載されています。 Model は UI に関係しない純粋なドメインロジックやそのデータを持ちます。 今回は入力されたメールアドレスとパスワードのValidationを行うModelを作ります。 ログイン機能はサーバーサイドにHTTPRequestを送って認証・認可を行いますが そのようにAPIを使う場合もModelになると思います。 enum LoginValidateError: Error { case emailEmpty case passwordEmpty var localizedDescription: String { switch self { case .emailEmpty: return "emailEmpty" case .passwordEmpty: return "passwordEmpty" } } } protocol ILoginValidator { func validate(email: String, password: String) -> String } final class LoginValidator: ILoginValidator { func validate(email: String, password: String) -> String { if email.isEmpty { return LoginValidateError.isEmailEmpty.localizedDescription } if password.isEmpty { return LoginValidateError.isPasswordEmpty.localizedDescription } return "" } } V: View Viewの説明として「iOSアプリ設計パターン入門」にこう記載されています。 View はユーザー操作の受け付けと、画面表示を担当するコンポーネントです。 import SwiftUI struct LoginView: View { @ObservedObject private var viewModel: LoginViewModel init(viewModel: LoginViewModel) { self.viewModel = viewModel } var body: some View { VStack { invalidMessage emailTextField passwordSecureField loginButton } .alert("ログイン成功", isPresented: $viewModel.isLoginCompleted) {} } var emailTextField: some View { TextField("Eメール", text: $viewModel.email) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .padding() .accessibility(identifier: "loginEmailTextField") } var passwordSecureField: some View { SecureField("パスワード", text: $viewModel.password) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .padding() .accessibility(identifier: "loginPasswordSecureField") } var loginButton: some View { Button(action: { viewModel.didTapLoginButton.send() }) { Text("ログイン") .frame(maxWidth: .infinity) .padding() .foregroundColor(.white) .background(Color(.orange)) .cornerRadius(24) .padding() .disabled(!viewModel.isLoginEnabled) .opacity(viewModel.isLoginEnabled ? 1: 0.5) .accessibility(identifier: "loginButton") } } var invalidMessage: some View { Text(viewModel.invalidMessage) .foregroundColor(.red) .accessibility(identifier: "loginInvalidMessage") } } VM: ViewModel ViewModelの役割は「iOSアプリ設計パターン入門」にこう記載されています。 ViewModel は View-Model 間の画面表示のための仲介役であり、次の責務を担います。 • View に表示するためのデータを保持する • View からイベントを受け取り、Model の処理を呼び出す • View からイベントを受け取り、加工して値を更新する import Foundation import Combine final class LoginViewModel: ObservableObject { // binding @Published var email: String = "" @Published var password: String = "" @Published var isLoginCompleted: Bool = false // Input let didTapLoginButton = PassthroughSubject<Void, Never>() // output @Published private(set) var isLoginEnabled: Bool = false @Published private(set) var invalidMessage: String = "" // cancellable private var cancellables = Set<AnyCancellable>() init(loginValidator: LoginValidator) { let isLoginEnabled = $invalidMessage.dropFirst().map { $0.isEmpty } let invalidMessage = Publishers.CombineLatest($email, $password) .dropFirst() .map { loginValidator.validate(email: $0, password: $1) } let isLoginCompleted = didTapLoginButton .flatMap { Just(true) } cancellables.formUnion([ isLoginEnabled.assign(to: \.isLoginEnabled, on: self), invalidMessage.assign(to: \.invalidMessage, on: self), isLoginCompleted.assign(to: \.isLoginCompleted, on: self) ]) } } Test UnitTest Model Modelは具体的な処理の結果が期待されたものになるかをテストしています。 今回はvalidate()の結果が期待されたものになるかをテストします。 import XCTest @testable import SwiftUI_MVVM_Login class LoginValidatorTests: XCTestCase { private let model = LoginValidator() func testValidateForNoError() throws { let email = "email" let password = "password" let expected = "" let actual = model.validate(email: email, password: password) XCTAssertEqual(expected, actual) } func testValidateForEmailEmpty() throws { let email = "" let password = "" let expected = LoginValidateError.emailEmpty.localizedDescription let actual = model.validate(email: email, password: password) XCTAssertEqual(expected, actual) } func testValidateForPasswordEmpty() throws { let email = "email" let password = "" let expected = LoginValidateError.passwordEmpty.localizedDescription let actual = model.validate(email: email, password: password) XCTAssertEqual(expected, actual) } } ViewModel ViewModelはViewとModelの橋渡し役なので Modelの処理結果が期待されたプロパティに反映されるかをテストしています。 今回はinvalidMessageが期待されたメッセージになっているかどうかをテストします。 また、LoginValidatorをILoginValidatorに準拠させることで ViewModelのUnitTestの際にDIでViewModelを生成でき LoginValidatorのMockを使えるようになります。 今回のUnitTestではそのままLoginValidatorを使っていますが このようにMockを使うこともできます。 // これが今回 private let viewModel = LoginViewModel(loginValidator: LoginValidator()) // Mockを使う場合 private let viewModel = LoginViewModel(loginValidator: MockLoginValidator()) import XCTest @testable import SwiftUI_MVVM_Login class LoginViewModelTests: XCTestCase { private let viewModel = LoginViewModel(loginValidator: LoginValidator()) func testNoInvalidMessage() throws { let expected = "" viewModel.email = "email" viewModel.password = "password" XCTAssertEqual(expected, viewModel.invalidMessage) } func testEmailInvalidMessage() throws { let expected = LoginValidateError.emailEmpty.localizedDescription viewModel.email = "" viewModel.password = "password" XCTAssertEqual(expected, viewModel.invalidMessage) } func testPasswordInvalidMessage() throws { let expected = LoginValidateError.passwordEmpty.localizedDescription viewModel.email = "email" viewModel.password = "" XCTAssertEqual(expected, viewModel.invalidMessage) } } UITest UITestでは, 何かしらの操作をした場合に期待された動きになるかをテストしています。 今回は、validationにかかったときはログインボタンが非活性化し、validateが全て通った場合にログインボタンが活性化し、タップできるようになるかどうかをテストします。 import XCTest import SwiftUI_MVVM_Login @testable import SwiftUI_MVVM_Login class LoginViewTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testCanLoginButtonTap() throws { let app = XCUIApplication() app.launch() let loginButton = app.buttons["loginButton"] XCTAssertEqual(false, loginButton.isEnabled) let emailTextField = app.textFields["loginEmailTextField"] let passwordSecureField = app.secureTextFields["loginPasswordSecureField"] emailTextField.tap() emailTextField.typeText("test") passwordSecureField.tap() passwordSecureField.typeText("111") XCTAssertEqual(true, loginButton.isEnabled) } } 成果物
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AtCoder Beginner Contest 229の[C - Cheese]をSwiftで

SwiftでAtCoder Beginner Contest 229の[C - Cheese]を解きました。 問題内容 問題文 ピザ屋で働く高橋くんは、まかないとして美味しいチーズピザを作ることにしました。 今、高橋くんの目の前に N 種類のチーズがあります。 i 種類目のチーズは 1 [g] あたりのおいしさが Aiで、Bi[g] あります。 ピザのおいしさは、ピザに乗せたチーズのおいしさの総和で決まります。 但し、チーズを使いすぎると怒られてしまうため、乗せたチーズの重さは合計で W [g] 以下である必要があります。 この条件のもとで、可能なピザのおいしさの最大値を求めてください。 解答コード func abc229C(){ var aa = readLine()!.split(separator:" ").map{Int($0)!} let n = aa[0] var w = aa[1] var result = 0 var array : [[Int]] = [] for _ in 0..<n { array.append(readLine()!.split(separator:" ").map{Int($0)!}) } array.sort{$0[0] > $1[0]} while w > 0{ for i in 0..<n { if array[i][1] <= w{ result += array[i][0] * array[i][1] w -= array[i][1] } else{ result += array[i][0] * w w = 0 } } break } print(result) } abc229C() AとBの配列の配列、二次元配列を作って、A(1gあたりのおいしさ)が大きい順に並び替えを行います。 あとは重さのキャパが0になるまでチーズの重さを追加していきます。 追加する際にはBをすべて使えるか、一部しか使えないかで振り分けしています。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift 古い非同期関数をasync/awaitで呼び出せるようにラップする

Swift で Future や Promise にあたるものを使いたくなった時に、毎回ググり直すので自分用のメモです。 わかりやすく 「古い」「新しい」と書いていますが単純に登場した順番であって、良し悪しの話ではないです。 古い関数をラップする こういう古い書き方の関数があり、都合によってこれ自体を書き換えれられない場合 Old.swift func old(completion: @escaping () -> Void) { print("処理中...") completion() } 新しくこういう風にラップしましょう New.swift func new() async { try! await withCheckedThrowingContinuation { continuation in old { continuation.resume() } } } withCheckedThrowingContinuation の中で古い関数を呼ぶことがポイントです 上記の例ではエラーが起こらない前提があるので try! を使っています ラップした関数を呼び出す すると今までと比べて綺麗に呼び出せるようになります。 OldAndNew.swift // 今まで通り old を呼び出す方法 print("処理前") old { print("処理後") } /* 処理前 処理中 処理後 */ // 新しくラップしなおした new を呼び出す方法 print("処理前") await new() print("処理後") /* 処理前 処理中 処理後 */ おまけ ちなみに SwiftUI の Button のように非同期のアクションに対応していない箇所から呼びたい場合は Task の中で使います。 SwiftUIButton.swift ... Task { print("処理前") await new() print("処理後") } ... コールバックで値を返す場合やエラーハンドリングに応じて .resume(returning:) や .resume(throwing:) , .resume(with:) といった関数も用意されています。 詳しくは withCheckedThrowingContinuation でググりましょう。(結局ググるんかい) これでようやくSwiftでもコールバック地獄から抜け出せる。やったぜ✌️
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む