はじめに
こんにちは!
iOSエンジニアになることを目指して、Swiftの勉強しておりますそこら辺の大学2年生です!
こちらの記事でも書かせていただきましたが、今から2ヶ月ほど前にCyberAgentさんが開催したCA Tech Dojoに参加してきました!
本記事では自分がインターンで学んできたことのアウトプットとして、実際にどのようにアプリを開発していったか当時考えていたことも踏まえて実際のコードと共に一部紹介できればなと思います!
改めてどんなアプリを作ったの?
簡潔に言うと、QRコードを元にTwiiterとGithubのアカウントを友達とアプリ内で交換することができるアプリを作りました!
実際に振り返ってみます!
インターンに参加する2、3週間前ぐらいからアーキテクチャ(MVVM)について学び始めていたので、それを取り入れて開発することを目標にしていました!
その中でも今回は、ログイン画面の実装について紹介させていただければなと思います!
技術スタック
MVVMを用いた実装(知見としてはまだまだです😭)
Combine&CombineCocoaを使用
データベースとしてUserDefaultを使用
ログイン画面の主な機能
- 自分の名前、Twitterのアカウント名、Githubのアカウント名の入力
- バリデーション機能をつける
- データの永続化
それではコードと共にModel層から見ていきたいと思います✌️
Model層
import Foundation
struct SelfProfile: Codable {
var accountName: String
var twitterID: String
var githubName: String
}
永続化行うためにCodableに準拠させました!
至ってシンプルなモデル定義となっています。
ViewModel層
ViewModelは少し長くなってしまうので、一部を抜粋してコードを紹介します
気になる方いらっしゃいましたらこちらのGithubからご確認いただければと思います🙏
まずはView層からイベントを送信するやり方です!
func bind() {
nameTextField.textPublisher
.assign(to: &viewModel.$accountName)
twitterIDTextField.textPublisher
.assign(to: &viewModel.$twitterID)
githubNameTextField.textPublisher
.assign(to: &viewModel.$githubName)
registerButton.tapPublisher
.sink { [weak self] _ in
self?.viewModel.registerButtonTapped.send()
}
.store(in: &subscriptions)
// .......以下省略
自分はUIKitで実装していたことから、UIKitでもCombineをうまく扱いたいと思いCombineCocoaを選択して、.textPublisherと.tapPublisherをはやしました。
このようにすることでイベントを検知して、ViewModelに送信します!
@Published var accountName: String?
@Published var twitterID: String?
@Published var githubName: String?
プロパティラッパーのPublishedを付与して、常に最新の値を取得できるようにします!
※CurrentValueSubjectを使用することもできます!
accountNameResult
.combineLatest(twitterIDResult, githubNameResult)
.map { accountNameResult, twitterIDResult, githubNameResult in
accountNameResult.isValid &&
twitterIDResult.isValid &&
githubNameResult.isValid
}
.assign(to: &$isRegisterButtonEnabled)
registerButtonTapped
.sink { [weak self] _ in
guard let self,
let accountName = self.accountName,
let twitterID = self.twitterID,
let githubName = self.githubName
else { return }
// ここのRepositoryについては続きに簡単に説明しています。
self.repository.selfProfile = SelfProfile(accountName: accountName, twitterID: twitterID, githubName: githubName)
if let profile = self.repository.selfProfile {
self.selfProfileSubject.send(profile)
}
}
.store(in: &subscriptions)
その後、CombineLatestを使って、すべてがTrueになった時のみ、ログインできるように実装しました。
Validationの実装については、当時かなり苦戦してしまい、友達に助けてもらいながら実装をしたので、完全に理解できているわけではないため説明を省きます🙇
バリデーションの実際の仕様☟
- TextFieldが空欄の時❌
- TwitterとGithubのアカウント名についてはアルファベットのみ許可。

文字潰れちゃってますね笑
詳細が気になる方はこちらのGithubからコードを確認してください
また、以下の記事をかなり参考させていただき、Validationは実装しました!
https://iganin.hatenablog.com/entry/2019/09/23/171221
Repositoryについて
具体的な説明は自分自身も学習中なため、詳細に説明することができないのですが、データがどこから取得されてるかなどの技術的詳細を隠蔽することで抽象化したレイヤに任せることができるというものです!このようにすることで、単体テストがしやすくなったり、ViewModelが肥大化しずらくなったりとメリットがたくさんあります!
なので本アプリでもその実装にチャレンジしました。
protocol SelfRepository: AnyObject {
var selfProfile: SelfProfile? { get set }
}
final class SelfRepositoryImpl: SelfRepository {
static let shared = SelfRepositoryImpl()
private init() {}
private let selfProfileSubject = PassthroughSubject<SelfProfile, Never>()
var selfProfile: SelfProfile? {
get {
guard let data = UserDefaults.standard.data(forKey: "selfProfile") else {
return nil
}
let profile = try? JSONDecoder().decode(SelfProfile.self, from: data)
return profile
}
set {
guard
let newValue,
let data = try? JSONEncoder().encode(newValue)
else { return }
UserDefaults.standard.setValue(data, forKey: "selfProfile")
selfProfileSubject.send(newValue)
}
}
}
View層
ViewModelから値を受け取ります!
viewModel.$isRegisterButtonEnabled
.sink { [weak self] isValid in
self?.registerButton.isEnabled = isValid
self?.registerButton.backgroundColor = isValid ? UIColorDefinition.heavyBlue : .lightGray
}
.store(in: &subscriptions)
viewModel.selfProfileSubject
.sink { [weak self] profile in
let nextVC = NextViewController(profile: profile)
nextVC.modalPresentationStyle = .fullScreen
nextVC.modalTransitionStyle = .flipHorizontal
self?.present(nextVC, animated: true)
}
.store(in: &subscriptions)
ログイン画面から入力された自分のデータは遷移先の画面で、QRコードを生成したり、そこから自分のデータを編集できるようにする実装だったため、イニシャライザでデータは渡しています!
またViewModelからValidationの結果が来るので、それに応じてボタンを有効にするかどうかを決めています!
実装で迷ったところ
TextFiledがタップされた時に、TextFiledにちょっとした影を付けたいと思いました。その時にCombineCocoaを使って実装するか、UITextFieldDelgateを使って実装するか迷いました。
結論としてUITextFiledDelegateを使って実装しました!
extension LoginViewController: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
// print("hi")
textField.layer.shadowOpacity = 1.0
textField.layer.shadowRadius = 4
textField.layer.shadowColor = UIColorDefinition.heavyBlue.cgColor
textField.layer.shadowOffset = CGSize(width: 0.0, height: 1.0)
}
func textFieldDidEndEditing(_ textField: UITextField) {
textField.layer.shadowColor = UIColor.clear.cgColor
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.view.endEditing(true)
}
}
通常は全体のコードを統一するためにもCombineCocoaの.controlEventPublisherを使うべきだろうと当時考えました。しかし、入力状態が解除されたときに影が残ってしまうという問題がありました。もちろん、.controlPublisherの性質上、入力状態が解除された時にsubscribeすることはできるのですが、そうすると下記のように冗長になってしまうと考えました。
nameTextField.controlEventPublisher(for: .editingDidBegin)
.sink { [weak self] _ in
self?.nameTextField.layer.shadowOpacity = 1.0
self?.nameTextField.layer.shadowRadius = 4
self?.nameTextField.layer.shadowColor = UIColorDefinition.heavyBlue.cgColor
self?.nameTextField.layer.shadowOffset = CGSize(width: 0.0, height: 1.0)
}
.store(in: &subscriptions)
nameTextField.controlEventPublisher(for: .editingDidEnd)
.sink { [weak self] _ in
self?.nameTextField.layer.shadowColor = UIColor.clear.cgColor
}
.store(in: &subscriptions)
// textField毎に同じ内容の処理を記述しなければならない...
そのため結論として、UITextFieldDelegateを使って実装しました!
しかし、何かしら解決法があるなと当時から思っておりますので、ご存じの方いらっしゃいましたらご教授していただけると幸いです🙇♂️
自分も学習を重ねて後ほど、過去の自分のコードにツッコミを入れたいと思います!
当時の状況
ログイン画面は課題アプリの必須機能ではなかったため、必須機能を実装し終わった後の追加機能として作りました!
先ほども述べましたが、当時バリデーションの機能の実装にかなり苦戦してしまい、CyberAgentさんの別のiOSのインターンに参加していた友達に1時間ほどみっちり教えてもらいました。
(かなり辛口で厳しい言葉を言われて、落ち込んで帰った記憶があります笑)
最後に
以上、ここまでがCA Tech Dojoで自分が作成したアプリの一部となります。
記事を書きながら思ったのですが、未だに理解が浅い部分があったりとまだまだ実力が足りてないなと改めて感じました!
これからも日々の学習を心がけて精進していけたらと思います。
ここまで読んでくれた皆様、ありがとうございました!
本記事で間違えてる所等ありましたら、ご指摘いただけると幸いです🙇♂️