Skip to content

Instantly share code, notes, and snippets.

@sakmt
Last active April 4, 2018 01:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sakmt/6d72487fec2538943f7d8b95d66dad2c to your computer and use it in GitHub Desktop.
Save sakmt/6d72487fec2538943f7d8b95d66dad2c to your computer and use it in GitHub Desktop.
2018/04/03のサポーターズ勉強会( https://supporterzcolab.com/event/342/ )のDEMOコードです
// - 業務のプロジェクトから抜粋して貼り付けているので見づらい上、実行には足りてないコードもあります: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