2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[iOS]いきなり!RxSwift (当方はSwift初心者でいきなりRxSwift!) よく使うRxSwift系ライブラリ RxKeyboard編

Posted at

#はじめに
前回に引き続き、よく使う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アプリのようなメッセージ形式のタイムライン&入力フォームのアプリケーションになります。

625eea7a-ddbe-11e6-9984-529abae1bd1a.gif14bd915c-8eb0-11e6-93ea-7618fc9c5d81.gif

#準備

1.プロジェクト作成

-Xcodeを起動
-Create a new Xcode project
 >Single View App
 >Product Name:RxKeyboardSample
 >完了。すぐにプロジェクトを閉じます。

2.ターミナルを起動して、ディレクトリに移動

$ cd RxKeyboardSample

3.Podfile作成/編集

$ pod init
$ vi Podfile

今回はたくさんのライブラリを使います

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の修正

AppDelegate.swift

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
を作成

スクリーンショット 2019-08-07 15.22.49.png

#Model
User、MessageのModel編集します。

User.swift
enum User {
    case other
    case me
}
Message.sweft
struct Message {
    var user: User
    var text: String
}

#View

MessageCell.swift
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
    }

}

MessageInputBar.swift
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

MessageListViewController.swift
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)
    }
}


#参考
https://github.com/RxSwiftCommunity/RxKeyboard

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?