#はじめに
前回に引き続き、よく使うRxSwift系ライブラリとして、RxKeyboardをみていきたいと思います。
#環境
Xcode10.3
Swift5.0.1
RxSwift 5.0.0
RxCocoa 5.0.0
RxKeyboard 1.0.0
SwiftyImage 1.1
SwiftyColor 1.2.0
CGFloatLiteral 0.5.0
ManualLayout 1.3.0
SnapKit 5.0.0
Then 2.5.0
ReusableKit 3.0.0
UITextView+Placeholder 1.2.1
#つくるもの
iOSのMessageアプリのようなメッセージ形式のタイムライン&入力フォームのアプリケーションになります。
#準備
1.プロジェクト作成
-Xcodeを起動
-Create a new Xcode project
>Single View App
>Product Name:RxKeyboardSample
>完了。すぐにプロジェクトを閉じます。
2.ターミナルを起動して、ディレクトリに移動
$ cd RxKeyboardSample
3.Podfile作成/編集
$ pod init
$ vi Podfile
今回はたくさんのライブラリを使います
target 'RxKeyboardSample' do
use_frameworks!
pod 'RxSwift'
pod 'RxCocoa'
pod 'RxKeyboard'
pod 'ReusableKit'
pod 'SwiftyImage'
pod 'SwiftyColor'
pod 'CGFloatLiteral'
pod 'ManualLayout'
pod 'SnapKit'
pod 'Then'
pod 'UITextView+Placeholder'
end
4.ライブラリのインストール
$ pod install
5.プロジェクトを開く
.xcworkspaceから起動する
#Storyboardを削除
今回は、Storyboardもxibも使わずに、プログラムに直書きでレイアウトを表示します。
1.Main.storyboardの削除
/Main.storyboardをDelete > Move to Trash
2.Info.plist
Info.plistを開く > Main storyboard file base nameの項目を削除(マイナスボタン)
3.AppDelegateの修正
import UIKit
import CGFloatLiteral
import ManualLayout
import SnapKit
import SwiftyColor
import Then
import UITextView_Placeholder
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
//こうすることでSwift4.1以下でもdidFinishLaunchingWithOptionsが呼ばれるようにできる
#if swift(>=4.2)
typealias ApplicationLaunchOptionsKey = UIApplication.LaunchOptionsKey
#else
typealias ApplicationLaunchOptionsKey = UIApplicationLaunchOptionsKey
#endif
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [ApplicationLaunchOptionsKey: Any]?
) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
window.backgroundColor = .white
window.makeKeyAndVisible()
let viewController = MessageListViewController()
let navigationController = UINavigationController(rootViewController: viewController)
window.rootViewController = navigationController
self.window = window
return true
}
}
#MVVM用のグループを作成
Project Navigator - プロジェクトフォルダ上で右クリック - New Group
より、Models、Views、ViewControllersの3つのグループを作成し、
/ViewControllers
・ViewController.swiftをViewControllersフォルダに移動
・ファイル名を、MessageListViewController.swiftに変更
・クラス名を、MessageListViewControllerに変更
/Views
・MessageCell.swift
・MessageInputBar.swift
を作成
/Models
・User.swift
・Message.swift
を作成
#Model
User、MessageのModel編集します。
enum User {
case other
case me
}
struct Message {
var user: User
var text: String
}
#View
import Foundation
import UIKit
import SwiftyImage
final class MessageCell: UICollectionViewCell {
// MARK: Types
fileprivate enum BalloonAlignment {
case left
case right
}
// MARK: Constants
struct Metric {
static let maximumBalloonWidth = 240.f
static let balloonViewInset = 10.f
}
struct Font {
static let label = UIFont.systemFont(ofSize: 14.f)
}
// MARK: Properties
fileprivate var balloonAlignment: BalloonAlignment = .left
// MARK: UI
//SwiftyImageによる拡張でオブジェクト作成
fileprivate let otherBalloonViewImage = UIImage.resizable()
.corner(radius: 5)
.color(0xD9D9D9.color)
.image
//SwiftyImageによる拡張でオブジェクト作成
fileprivate let myBalloonViewImage = UIImage.resizable()
.corner(radius: 5)
.color(0x1680FA.color)
.image
let balloonView = UIImageView()
let label = UILabel().then {
$0.font = Font.label
$0.numberOfLines = 0
}
// MARK: Initializing
override init(frame: CGRect) {
super.init(frame: frame)
self.contentView.addSubview(self.balloonView)
self.contentView.addSubview(self.label)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Configuring
func configure(message: Message) {
self.label.text = message.text
switch message.user {
case .other:
self.balloonAlignment = .left
self.balloonView.image = self.otherBalloonViewImage
self.label.textColor = .black
case .me:
self.balloonAlignment = .right
self.balloonView.image = self.myBalloonViewImage
self.label.textColor = .white
}
self.setNeedsLayout() //再読み込み
}
// MARK: Size
class func size(thatFitsWidth width: CGFloat, forMessage message: Message) -> CGSize {
let labelWIdth = Metric.maximumBalloonWidth - Metric.balloonViewInset * 2
let constraintSize = CGSize(width: labelWIdth, height: CGFloat.greatestFiniteMagnitude)
let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
let rect = message.text.boundingRect(with: constraintSize,
options: options,
attributes: [.font: Font.label],
context: nil)
let labelHeight = ceil(rect.height)
return CGSize(width: width, height: labelHeight + Metric.balloonViewInset * 2)
}
// MARK: Layout
override func layoutSubviews() {
super.layoutSubviews()
//ManualLayout によるプロパティ操作
self.label.width = Metric.maximumBalloonWidth - Metric.balloonViewInset * 2
self.label.sizeToFit()
self.balloonView.width = self.label.width + Metric.balloonViewInset * 2
self.balloonView.height = self.label.height + Metric.balloonViewInset * 2
switch self.balloonAlignment {
case .left:
self.balloonView.left = 10
case .right:
self.balloonView.right = self.contentView.width - 10
}
self.label.top = self.balloonView.top + Metric.balloonViewInset
self.label.left = self.balloonView.left + Metric.balloonViewInset
}
}
import Foundation
import UIKit
import RxCocoa
import RxSwift
import RxKeyboard
final class MessageInputBar: UIView {
// MARK: Properties
private let disposeBag = DisposeBag()
// MARK: UI
let toolbar = UIToolbar()
let textView = UITextView().then {
$0.placeholder = "Say Hi!"
$0.isEditable = true
$0.showsVerticalScrollIndicator = false
$0.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.6).cgColor
$0.layer.borderWidth = 1 / UIScreen.main.scale
$0.layer.cornerRadius = 3
}
let sendButton = UIButton(type: .system).then {
$0.titleLabel?.font = UIFont.boldSystemFont(ofSize: UIFont.systemFontSize)
$0.setTitle("Send", for: .normal)
}
// MARK: Initializing
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.toolbar)
self.addSubview(self.textView)
self.addSubview(self.sendButton)
//snp:SnapKit は AutoLayout の DSL
self.toolbar.snp.makeConstraints { make in
make.edges.equalTo(0)
}
self.textView.snp.makeConstraints { make in
make.top.left.equalTo(7)
make.right.equalTo(self.sendButton.snp.left).offset(-7)
make.bottom.equalTo(-7)
}
self.sendButton.snp.makeConstraints { make in
make.top.equalTo(7)
make.bottom.equalTo(-7)
make.right.equalTo(-7)
}
self.textView.rx.text
.map { text in
//1行記述はreturnを省略できる
text?.isEmpty == false //textViewのtextを監視し、textが空でなければ
}
.bind(to: self.sendButton.rx.isEnabled) //ボタンを押せるように
.disposed(by: self.disposeBag)
RxKeyboard.instance.visibleHeight
.map{ $0 > 0}
.distinctUntilChanged().drive(onNext: { [weak self] (visible) in
guard let self = self else {
return
}
var bottomInset = 0.f
if #available(iOS 11.0, *), !visible, let bottom = self.superview?.safeAreaInsets.bottom {
bottomInset = bottom
}
self.toolbar.snp.remakeConstraints({ (make) in
make.left.right.top.equalTo(0)
make.bottom.equalTo(bottomInset)
})
})
.disposed(by: disposeBag)
}
@available(iOS 11.0, *)
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
guard let bottomInset = self.superview?.safeAreaInsets.bottom else {
return
}
self.toolbar.snp.remakeConstraints { make in
make.top.left.right.equalTo(0)
make.bottom.equalTo(bottomInset)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Size
//自身のサイズを返す
override var intrinsicContentSize: CGSize {
return CGSize(width: self.width, height: 44)
}
}
// MARK: - Reactive
//Rasctive
extension Reactive where Base: MessageInputBar { //base変数の型がMessageInputBarになる
var sendButtonTap: ControlEvent<String> {
let source: Observable<String> = self.base.sendButton.rx.tap.withLatestFrom(self.base.textView.rx.text.asObservable())
.flatMap { text -> Observable<String> in
if let text = text, !text.isEmpty {
return .just(text)
} else {
return .empty()
}
}
.do(onNext: { [weak base = self.base] _ in
base?.textView.text = nil
})
return ControlEvent(events: source)
}
}
#ViewController
import UIKit
import ReusableKit
import RxKeyboard
import RxSwift
class MessageListViewController: UIViewController {
// MARK: Constants
struct Reusable {
static let messageCell = ReusableCell<MessageCell>() //ReusableCell:ReusableKitのクラス
}
// MARK: Properties
private var didSetupViewConstaraints = false
private var disposeBag = DisposeBag()
fileprivate var messages: [Message] = [
Message(user: .other, text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
Message(user: .other, text: "Morbi et eros elementum, semper massa eu, pellentesque sapien."),
Message(user: .me, text: "Aenean sollicitudin justo scelerisque tincidunt venenatis."),
Message(user: .me, text: "Ut mollis magna nec interdum pellentesque."),
Message(user: .me, text: "Aliquam semper nibh nec quam dapibus, a congue odio consequat."),
Message(user: .other, text: "Nullam iaculis nisi in justo feugiat, at pharetra nulla dignissim."),
Message(user: .me, text: "Fusce at nulla luctus, posuere mauris ut, viverra nunc."),
Message(user: .other, text: "Nam feugiat urna non tortor ornare viverra."),
Message(user: .other, text: "Donec vitae metus maximus, efficitur urna ac, blandit erat."),
Message(user: .other, text: "Pellentesque luctus eros ac nisi ullamcorper pharetra nec vel felis."),
Message(user: .me, text: "Duis vulputate magna quis urna porttitor, tempor malesuada metus volutpat."),
Message(user: .me, text: "Duis aliquam urna quis metus tristique eleifend."),
Message(user: .other, text: "Cras quis orci quis nisi vulputate mollis ut vitae magna."),
Message(user: .other, text: "Fusce eu urna eu ipsum laoreet lobortis."),
Message(user: .other, text: "Proin vitae tellus nec odio consequat varius ac non orci."),
Message(user: .me, text: "Maecenas gravida arcu ut consectetur tincidunt."),
Message(user: .me, text: "Quisque accumsan nisl ut ipsum rutrum, nec rutrum magna lobortis."),
Message(user: .other, text: "Integer ac sem eu velit tincidunt hendrerit a in dui."),
Message(user: .other, text: "Duis posuere arcu convallis tincidunt faucibus."),
]
// MARK: UI
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()).then {
$0.alwaysBounceVertical = true
$0.keyboardDismissMode = .interactive
$0.backgroundColor = .clear
$0.register(Reusable.messageCell)
($0.collectionViewLayout as? UICollectionViewFlowLayout)?.do({
$0.minimumLineSpacing = 6
$0.sectionInset.top = 10
$0.sectionInset.bottom = 10
})
}
let messageInputBar = MessageInputBar()
// MARK: Initializing
init() {
super.init(nibName: nil, bundle: nil)
self.title = "RxKeyboard Example"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.collectionView)
self.view.addSubview(self.messageInputBar)
self.collectionView.dataSource = self
self.collectionView.delegate = self
DispatchQueue.main.async {
let indexPath = IndexPath(item: self.messageInputBar.accessibilityElementCount() - 1, section: 0)
self.collectionView.scrollToItem(at: indexPath, at: [], animated: true)
}
RxKeyboard.instance.visibleHeight
.drive(onNext: { [weak self] keyboardVisibleHeight in
guard let self = self, self.didSetupViewConstaraints else { return }
self.messageInputBar.snp.updateConstraints { make in
if #available(iOS 11.0, *) {
make.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-keyboardVisibleHeight)
} else {
make.bottom.equalTo(self.bottomLayoutGuide.snp.top).offset(-keyboardVisibleHeight)
}
}
self.view.setNeedsLayout()
UIView.animate(withDuration: 0) {
self.collectionView.contentInset.bottom = keyboardVisibleHeight + self.messageInputBar.height
self.collectionView.scrollIndicatorInsets.bottom = self.collectionView.contentInset.bottom
self.view.layoutIfNeeded()
}
})
.disposed(by: self.disposeBag)
RxKeyboard.instance.willShowVisibleHeight
.drive(onNext: { keyboardVisibleHeight in
self.collectionView.contentOffset.y += keyboardVisibleHeight
})
.disposed(by: self.disposeBag)
self.messageInputBar.rx.sendButtonTap
.subscribe(onNext: { [weak self] text in
guard let self = self else { return }
let message = Message(user: .me, text: text)
self.messages.append(message)
let indexPath = IndexPath(item: self.messages.count - 1, section: 0)
self.collectionView.insertItems(at: [indexPath])
self.collectionView.scrollToItem(at: indexPath, at: [], animated: true)
})
.disposed(by: disposeBag)
}
override func updateViewConstraints() {
super.updateViewConstraints()
guard !self.didSetupViewConstaraints else { return }
self.didSetupViewConstaraints = true
self.collectionView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
self.messageInputBar.snp.makeConstraints { make in
make.left.right.equalTo(0)
if #available(iOS 11.0, *) {
make.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom)
} else {
make.bottom.equalTo(self.bottomLayoutGuide.snp.top)
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if self.collectionView.contentInset.bottom == 0 {
self.collectionView.contentInset.bottom = self.messageInputBar.height
self.collectionView.scrollIndicatorInsets.bottom = self.collectionView.contentInset.bottom
}
}
}
// MARK: - UICOllectionViewDataSource
extension MessageListViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.messages.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeue(Reusable.messageCell, for: indexPath)
cell.configure(message: self.messages[indexPath.item])
return cell
}
}
// MARK: - UICollectionViewDelegateFlowLayout
extension MessageListViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let message = self.messages[indexPath.item]
return MessageCell.size(thatFitsWidth: collectionView.width, forMessage: message)
}
}