Last active
April 4, 2018 01:36
-
-
Save sakmt/6d72487fec2538943f7d8b95d66dad2c to your computer and use it in GitHub Desktop.
2018/04/03のサポーターズ勉強会( https://supporterzcolab.com/event/342/ )のDEMOコードです
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// - 業務のプロジェクトから抜粋して貼り付けているので見づらい上、実行には足りてないコードもあります:innocent: | |
// - UI構築にSnapKit、View要素のバインディングやイベントまわりにRxSwiftを利用しています。 | |
// - StoryBoardやXibは使用していません | |
import UIKit | |
import CoreLocation | |
import SnapKit | |
import RxSwift | |
// | |
// Extensions.swift | |
// ================================ | |
// Kotlinのapply関数みたいなやつ | |
protocol ApplyProtocol {} | |
extension ApplyProtocol { | |
@discardableResult func apply(_ closure: (_ this: Self) -> Void) -> Self { | |
closure(self) | |
return self | |
} | |
} | |
extension NSObject: ApplyProtocol {} | |
// | |
// InputFieldType.swift | |
// ================================ | |
enum InputFieldType | |
{ | |
case sei | |
case mei | |
case seiKana | |
case meiKana | |
case postalCode | |
case prefecture | |
case city | |
case street | |
case building | |
case phoneNumber | |
} | |
extension InputFieldType | |
{ | |
var label: String { | |
switch self { | |
case .sei: return "姓" | |
case .mei: return "名" | |
case .seiKana: return "セイ(全角カナ)" | |
case .meiKana: return "メイ(全角カナ)" | |
case .postalCode: return "郵便番号" | |
case .prefecture: return "都道府県" | |
case .city: return "市区町村" | |
case .street: return "町名・番地" | |
case .building: return "建物名・部屋番号" | |
case .phoneNumber: return "電話番号" | |
} | |
} | |
var example: String? { | |
switch self { | |
case .sei: return "佐藤" | |
case .mei: return "花子" | |
case .seiKana: return "サトウ" | |
case .meiKana: return "ハナコ" | |
case .postalCode: return "1500045" | |
case .prefecture: return "東京都" | |
case .city: return "渋谷区" | |
case .street: return "神泉町8-16" | |
case .building: return "渋谷ファーストプレイス8F" | |
case .phoneNumber: return "08012345678" | |
} | |
} | |
var placeholder: String { | |
let ex = (example != nil) ? " 例: \(example!)" : "" | |
return "\(label)\(ex)" | |
} | |
var next: InputFieldType? { | |
switch self { | |
case .sei: return .mei | |
case .mei: return .seiKana | |
case .seiKana: return .meiKana | |
case .meiKana: return .postalCode | |
case .postalCode: return .street // 都道府県と市区町村は自動で埋まるためジャンプ | |
case .prefecture: return .city | |
case .city: return .street | |
case .street: return .building | |
case .building: return .phoneNumber | |
case .phoneNumber: return nil | |
} | |
} | |
var keyboardType: UIKeyboardType { | |
if [.postalCode, .phoneNumber].contains(self) { | |
return .numberPad | |
} | |
return .default | |
} | |
var pickerDataSource: [String] { | |
switch self { | |
// 都道府県順序 https://ja.wikipedia.org/wiki/%E5%85%A8%E5%9B%BD%E5%9C%B0%E6%96%B9%E5%85%AC%E5%85%B1%E5%9B%A3%E4%BD%93%E3%82%B3%E3%83%BC%E3%83%89 | |
case .prefecture: return ["", "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県", "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", "鳥取県", "島根県", "岡山県", "広島県", "山口県", "徳島県", "香川県", "愛媛県", "高知県", "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県"] | |
default: return [] | |
} | |
} | |
var usePicker: Bool { | |
return !pickerDataSource.isEmpty | |
} | |
/// バリデーションしてエラーメッセージ or nil を返す | |
func notice(for input: String) -> String? { | |
switch self { | |
case .seiKana, .meiKana: | |
if NSPredicate(format: "SELF MATCHES %@", "^[ァ-ヾ]+$").evaluate(with: input) { return nil } | |
return "カタカナで入力してください" | |
case .postalCode: | |
if input.matchRegExp("\\A[0-9]{7}\\z") { return nil } | |
return "数字7桁で入力してください" | |
case .building: // 「建物名・部屋番号」は空入力を許容 | |
return nil | |
case .phoneNumber: | |
if input.matchRegExp("\\A0[0-9]{9,10}\\z") { return nil } | |
return "数字のみで正しく入力してください" | |
default: | |
if input.matchRegExp(".+") { return nil } | |
return "入力してください" | |
} | |
} | |
} | |
// | |
// InputField.swift | |
// ================================ | |
final class InputField: UIView | |
{ | |
let type: InputFieldType | |
let isValid = Variable(false) | |
let returnEvent = PublishSubject<InputField>() | |
let editingEndEvent = PublishSubject<InputField>() | |
var text: String { | |
get { | |
return textField.text ?? "" | |
} | |
set { | |
textField.text = newValue | |
textField.sendActions(for: .valueChanged) | |
} | |
} | |
private let disposeBag = DisposeBag() | |
private var label: UILabel! | |
private var notice: UILabel! | |
private var textField: UITextField! | |
init(type: InputFieldType) { | |
self.type = type | |
super.init(frame: .zero) | |
makeViews(type: type) | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override var isFirstResponder: Bool { | |
return super.isFirstResponder || textField.isFirstResponder | |
} | |
@discardableResult override func becomeFirstResponder() -> Bool { | |
return textField.becomeFirstResponder() | |
} | |
@discardableResult override func resignFirstResponder() -> Bool { | |
return textField.resignFirstResponder() | |
} | |
} | |
extension InputField | |
{ | |
private func makeViews(type: InputFieldType) { | |
backgroundColor = .white | |
snp.makeConstraints { make in | |
make.height.equalTo(48) | |
} | |
// プレイスホルダ(入力時は項目のラベルになる) | |
label = UILabel().apply { this in | |
addSubview(this) | |
this.text = type.placeholder | |
this.font = .systemFont(ofSize: 14) | |
this.textColor = .lightGray | |
this.snp.makeConstraints { make in | |
make.left.equalToSuperview().inset(16) | |
make.right.equalToSuperview() | |
make.centerY.equalToSuperview().offset(4) | |
} | |
} | |
// エラー表示 | |
notice = UILabel().apply { this in | |
addSubview(this) | |
this.font = .systemFont(ofSize: 11) | |
this.textColor = .mylish | |
this.snp.makeConstraints { make in | |
make.left.equalToSuperview().inset(120) | |
make.centerY.equalToSuperview().offset(-14) | |
} | |
} | |
// 入力を受け付けるフィールド | |
textField = UITextField().apply { this in | |
addSubview(this) | |
this.font = .systemFont(ofSize: 14) | |
this.isSecureTextEntry = type.isSecureTextEntry | |
this.keyboardType = type.keyboardType | |
this.returnKeyType = .continue | |
this.snp.makeConstraints { make in | |
make.top.equalToSuperview().inset(12) | |
make.left.equalToSuperview().inset(16) | |
make.right.bottom.equalToSuperview() | |
} | |
// 選択肢からの入力 | |
if type.usePicker { | |
this.inputView = UIPickerView().apply { picker in | |
// bind DataSource | |
Observable.just(type.pickerDataSource) | |
.bind(to: picker.rx.itemTitles) { _, item in return item } | |
.disposed(by: disposeBag) | |
// ピッカー選択時の処理 | |
picker.rx.modelSelected(String.self) | |
.map { $0.first ?? "" } | |
.subscribe(onNext: { text in | |
this.text = text | |
this.sendActions(for: .valueChanged) | |
}) | |
.disposed(by: disposeBag) | |
// 入力されている文字列をピッカーの中心にしておく | |
this.rx.controlEvent(.editingDidBegin) | |
.subscribe(onNext: { _ in | |
picker.selectRow(type.pickerDataSource.index(of: this.text ?? "") ?? 0, inComponent: 0, animated: false) | |
}) | |
.disposed(by: disposeBag) | |
} | |
} | |
// 年と月の入力 | |
if type.useDatePicker { | |
this.inputView = MonthYearPickerView().apply { picker in | |
picker.selectEvent | |
.subscribe(onNext: { text in | |
this.text = text | |
this.sendActions(for: .valueChanged) | |
}) | |
.disposed(by: disposeBag) | |
} | |
} | |
let noticeMessage = this.rx.text | |
.map { type.notice(for: $0 ?? "") } | |
.share(replay: 1) | |
// バリデーションメッセージ表示 | |
noticeMessage | |
.skipUntil(this.rx.controlEvent(.editingDidBegin)) | |
.bind(to: notice.rx.text) | |
.disposed(by: disposeBag) | |
// isValidへのバインディング | |
noticeMessage | |
.map { $0 == nil } | |
.bind(to: isValid) | |
.disposed(by: disposeBag) | |
// プレイスホルダのアニメーション | |
this.rx.text | |
.subscribe(onNext: { text in | |
self.morphPlaceholder(isClear: text?.isEmpty ?? true) | |
}) | |
.disposed(by: disposeBag) | |
// リターンキーの通知 | |
this.rx.controlEvent(.editingDidEndOnExit) | |
.map { self } | |
.bind(to: returnEvent) | |
.disposed(by: disposeBag) | |
// 編集終了の通知 | |
this.rx.controlEvent(.editingDidEnd) | |
.map { self } | |
.bind(to: editingEndEvent) | |
.disposed(by: disposeBag) | |
} | |
// 下線 | |
UIView().apply { this in | |
addSubview(this) | |
this.backgroundColor = .lightGray | |
this.snp.makeConstraints { make in | |
make.left.equalToSuperview().inset(16) | |
make.right.bottom.equalToSuperview() | |
make.height.equalTo(0.5) | |
} | |
} | |
} | |
/// 入力文字列の有り無しでプレイスホルダをアニメーションする | |
private func morphPlaceholder(isClear: Bool) { | |
label.text = isClear ? type.placeholder : type.label | |
label.snp.updateConstraints { make in | |
make.centerY.equalToSuperview().offset(isClear ? 5 : -14) | |
} | |
UIView.animate(withDuration: 0.1) { | |
self.label.font = .systemFont(ofSize: isClear ? 14 : 11) | |
self.layoutIfNeeded() | |
} | |
} | |
} | |
// | |
// AddressEditViewController.swift | |
// ================================ | |
final class AddressEditViewController: UIViewController | |
{ | |
var finishEvent = PublishSubject<Address>() | |
private let disposeBag = DisposeBag() | |
private let buttonText: String | |
private let initialAddress: Address | |
private let inputFieldTypes: [InputFieldType] = | |
[.sei, .mei, .seiKana, .meiKana, .postalCode, .prefecture, .city, .street, .building, .phoneNumber] | |
private var inputFields: [InputFieldType: InputField] = [:] | |
private var scrollView: UIScrollView! | |
private var nextButton: UIButton! | |
init(buttonText: String, address: Address) { | |
self.buttonText = buttonText | |
initialAddress = address | |
super.init(nibName: nil, bundle: nil) | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func loadView() { | |
super.loadView() | |
makeViews() | |
setKeyboardEvents() | |
} | |
} | |
extension AddressEditViewController | |
{ | |
private func makeViews() { | |
view.backgroundColor = .white | |
// 任意の箇所をタップでキーボードを閉じる | |
view.rx.tapEvent | |
.subscribe(onNext: { [unowned self] _ in | |
self.view.endEditing(true) | |
}) | |
.disposed(by: disposeBag) | |
scrollView = UIScrollView().apply { this in | |
view.addSubview(this) | |
this.snp.makeConstraints { make in | |
make.edges.equalToSuperview() | |
} | |
} | |
let header = HeaderLabel(text: "お届け先を入力").apply { this in | |
scrollView.addSubview(this) | |
this.snp.makeConstraints { make in | |
make.top.equalToSuperview().inset(12) | |
make.left.right.equalToSuperview().inset(16) | |
} | |
} | |
let contentStack = UIStackView().apply { this in | |
scrollView.addSubview(this) | |
this.axis = .vertical | |
this.snp.makeConstraints { make in | |
make.top.equalTo(header.snp.bottom).offset(4) | |
make.left.right.bottom.equalToSuperview() | |
make.width.equalTo(scrollView) | |
} | |
} | |
inputFieldTypes.forEach { type in | |
inputFields[type] = InputField(type: type).apply { this in | |
this.text = initialAddress[type] | |
this.returnEvent | |
.subscribe(onNext: { [unowned self] inputField in | |
self.focusNextField(focusedField: inputField) | |
}) | |
.disposed(by: disposeBag) | |
this.editingEndEvent | |
.filter { _ in type == .postalCode } | |
.subscribe(onNext: { [unowned self] inputField in | |
self.fillLocation(with: inputField.text) | |
}) | |
.disposed(by: disposeBag) | |
} | |
} | |
contentStack.addArrangedSubview(AddressHeaderView(string: "お名前")) | |
contentStack.addArrangedSubview(inputFields[.sei]!) | |
contentStack.addArrangedSubview(inputFields[.mei]!) | |
contentStack.addArrangedSubview(inputFields[.seiKana]!) | |
contentStack.addArrangedSubview(inputFields[.meiKana]!) | |
contentStack.addArrangedSubview(AddressHeaderView(string: "お届け先", notice: "必須, 日本国外・離島の方はご利用いただけません")) | |
contentStack.addArrangedSubview(inputFields[.postalCode]!) | |
contentStack.addArrangedSubview(inputFields[.prefecture]!) | |
contentStack.addArrangedSubview(inputFields[.city]!) | |
contentStack.addArrangedSubview(inputFields[.street]!) | |
contentStack.addArrangedSubview(inputFields[.building]!) | |
contentStack.addArrangedSubview(AddressHeaderView(string: "電話番号")) | |
contentStack.addArrangedSubview(inputFields[.phoneNumber]!) | |
let bottomView = UIView().apply { this in | |
contentStack.addArrangedSubview(this) | |
this.snp.makeConstraints { make in | |
make.height.equalTo(84) | |
} | |
} | |
ConfirmButton(okText: buttonText, ngText: "入力してください").apply { this in | |
bottomView.addSubview(this) | |
this.snp.makeConstraints { make in | |
make.left.right.equalToSuperview().inset(16) | |
make.centerY.equalToSuperview() | |
} | |
// enabled/disabled切り替え | |
Observable.combineLatest(inputFields.map{ key, value in value.isValid.asObservable().distinctUntilChanged() }) | |
.map { validities -> Bool in | |
return validities.filter{ !$0 }.isEmpty | |
} | |
.distinctUntilChanged() | |
.bind(to: this.rx.isEnabled) | |
.disposed(by: disposeBag) | |
// タップ時の処理 | |
this.confirmEvent | |
.subscribe(onNext: { [unowned self] _ in | |
self.view.endEditing(true) | |
self.finishEvent.onNext(Address(inputFields: self.inputFields)) | |
}) | |
.disposed(by: disposeBag) | |
} | |
// キーボード表示時、次の入力欄へ移動するためのボタン | |
nextButton = NextFieldButton().apply { this in | |
view.addSubview(this) | |
this.snp.makeConstraints { make in | |
make.right.bottom.equalToSuperview().inset(4) | |
} | |
this.rx.tap | |
.subscribe(onNext: { [unowned self] _ in | |
guard let focused = self.inputFields.filter({ $1.isFirstResponder }).first?.value else { return } | |
self.focusNextField(focusedField: focused) | |
}) | |
.disposed(by: disposeBag) | |
} | |
} | |
/// キーボードの表示・非表示に伴う処理 | |
private func setKeyboardEvents() { | |
let center = NotificationCenter.default | |
center.rx.notification(.UIKeyboardWillShow) | |
.map { _ in false } | |
.bind(to: nextButton.rx.isHidden) | |
.disposed(by: disposeBag) | |
center.rx.notification(.UIKeyboardWillHide) | |
.subscribe(onNext: { [unowned self] notification in | |
self.nextButton.isHidden = true | |
guard let userInfo = notification.userInfo, | |
let duration = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? TimeInterval else { return } | |
self.scrollView.snp.updateConstraints { make in | |
make.bottom.equalToSuperview() | |
} | |
UIView.animate(withDuration: duration) { self.view.layoutIfNeeded() } | |
}) | |
.disposed(by: disposeBag) | |
center.rx.notification(.UIKeyboardWillChangeFrame) | |
.subscribe(onNext: { [unowned self] notification in | |
guard let userInfo = notification.userInfo, | |
let keyboardHeight = (userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height, | |
let duration = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? TimeInterval else { return } | |
self.scrollView.snp.updateConstraints { make in | |
make.bottom.equalToSuperview().inset(keyboardHeight) | |
} | |
self.nextButton.snp.updateConstraints { make in | |
make.bottom.equalToSuperview().inset(keyboardHeight + 4) | |
} | |
UIView.animate(withDuration: duration) { self.view.layoutIfNeeded() } | |
}) | |
.disposed(by: disposeBag) | |
} | |
/// 次の入力フィールドへフォーカス移動 | |
private func focusNextField(focusedField: InputField) { | |
focusedField.resignFirstResponder() | |
guard let next = focusedField.type.next else { return } | |
inputFields[next]?.becomeFirstResponder() | |
} | |
/// 郵便番号からの住所自動入力 | |
private func fillLocation(with postalCode: String) { | |
CLGeocoder().geocodeAddressString("〒\(postalCode)", completionHandler: { [unowned self] placemarks, error in | |
self.inputFields[.prefecture]?.text = placemarks?.first?.administrativeArea ?? "" | |
self.inputFields[.city]?.text = placemarks?.first?.locality ?? "" | |
}) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment