Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 채팅 UI (UICollectionView를 사용하여 chatting UI 구현) 본문

UI 컴포넌트 (swift)

[iOS - swift] 채팅 UI (UICollectionView를 사용하여 chatting UI 구현)

jake-kim 2021. 10. 31. 21:01

구현 아이디어

  • UICollectionView 사용하고 layout은 IUCollectionViewFlowLayout 인스턴스 사용
  • UITextView를 갖는 커스텀 cell을 만든 후, 말풍선의 tipView는 CGMutablePath를 통해 그려주는 방식

커스텀 채팅 Cell 구현

class ChatMessageCell: BaseCollectionViewCell {
   // 구현
}
  • 필요한 모델을 nested로 정의
enum ChatType: CaseIterable {
    case receive
    case send
}

struct Model {
    let message: String
    let chatType: ChatType
}

var model: Model? {
    didSet { bind() }
}
  • UI 컴포넌트 초기화
let messaageTextView: UITextView = {
    let view = UITextView()
    view.font = .systemFont(ofSize: 18.0)
    view.text = "Sample message"
    view.textColor = .black
    view.backgroundColor = .white
    view.layer.cornerRadius = 15.0
    view.layer.masksToBounds = false
    view.isEditable = false
    return view
}()

let profileImageView: UIImageView = {
    let view = UIImageView(image: UIImage(named: "profile"))
    view.layer.cornerRadius = view.bounds.width / 2
    view.layer.borderWidth = 1
    view.layer.borderColor = UIColor.clear.cgColor
    return view
}()

override func setupViews() {
    super.setupViews()

    contentView.addSubview(messaageTextView)
    messaageTextView.translatesAutoresizingMaskIntoConstraints = false
    messaageTextView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
    messaageTextView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true

    contentView.addSubview(profileImageView)
    profileImageView.translatesAutoresizingMaskIntoConstraints = false
    profileImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8).isActive = true
}
  • bind() 메소드에서 사용되는 메소드
    • message 텍스트 값을 받아서, View의 width크기를 계산 String의 extension으로 `getEstimatedFrame(with:)`구현
    • receive인지 send인지 파악 후 말풍선의 tipView를 붙여야 하므로 UIView의 extension으로  `addTipViewToLeftTop(with:)`와 `addTipViewToRightBottom(with:)` 구현
// String값을 가지고 예상되는 frame크기를 return 하는 메소드 정의

extension String {
    func getEstimatedFrame(with font: UIFont) -> CGRect {
        let size = CGSize(width: UIScreen.main.bounds.width * 2/3, height: 1000)
        let optionss = NSStringDrawingOptions.usesFontLeading.union(.usesLineFragmentOrigin)
        let estimatedFrame = NSString(string: self).boundingRect(with: size, options: optionss, attributes: [.font: font], context: nil)
        return estimatedFrame
    }
}
// UIView에서 tipView를 왼쪽 상단 또는 오른쪽 하단에 표출하기 위한 메소드 정의

extension UIView {
    func addTipViewToLeftTop(with color: UIColor?) {

        let path = CGMutablePath()
        path.move(to: CGPoint(x: -8, y: 16))
        path.addLine(to: CGPoint(x: 12, y: 16))
        path.addLine(to: CGPoint(x: 12, y: 2))

        let shape = CAShapeLayer()
        shape.path = path
        shape.fillColor = color?.cgColor
        layer.insertSublayer(shape, at: 0)
    }

    func addTipViewToRightBottom(with color: UIColor?) {

        // frame 값을 얻기 위해서 layoutIfNeeded() 호출 (호출 안하면 width, height값 모두 0인 상태)
        layoutIfNeeded()

        print(frame)

        let height = frame.height
        let width = frame.width

        let path = CGMutablePath()
        path.move(to: CGPoint(x: width + 12, y: height - 18))
        path.addLine(to: CGPoint(x: width - 8, y: height - 18))
        path.addLine(to: CGPoint(x: width - 8, y: height - 4))

        let shape = CAShapeLayer()
        shape.path = path
        shape.fillColor = color?.cgColor
        layer.insertSublayer(shape, at: 0)
    }
}
  • bind() 메소드
private func bind() {
    guard let model = model, let font = messaageTextView.font else {
        return
    }
    messaageTextView.text = model.message
    let estimatedFrame = model.message.getEstimatedFrame(with: font)

    messaageTextView.widthAnchor.constraint(equalToConstant: estimatedFrame.width + 16).isActive = true

    if case .receive = model.chatType {
        messaageTextView.backgroundColor = .systemBlue
        profileImageView.isHidden = true
        messaageTextView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16).isActive = true
        messaageTextView.addTipViewToRightBottom(with: messaageTextView.backgroundColor)
    } else {
        messaageTextView.backgroundColor = .white
        profileImageView.isHidden = false
        messaageTextView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + profileImageView.bounds.width).isActive = true
        messaageTextView.addTipViewToLeftTop(with: messaageTextView.backgroundColor)
    }
}

ViewController에서 사용

  • UICollectionView 초기화 시, 정의한 Cell을 적용하여 사용
lazy var collectionView: UICollectionView = {
    let layout = UICollectionViewFlowLayout()
    layout.minimumLineSpacing = 16.0
    let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
    view.register(ChatMessageCell.self, forCellWithReuseIdentifier: "cell")
    view.delegate = self
    view.dataSource = self
    view.backgroundColor = .lightGray
    return view
}()
  • collectionView 관련 delegate
var messages: [(message: String, chatType: ChatMessageCell.ChatType)] = []

...

extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return messages.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! ChatMessageCell
        let message = messages[indexPath.row]
        cell.model = .init(message: message.message, chatType: message.chatType)
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let estimatedFrame = messages[indexPath.row].message.getEstimatedFrame(with: .systemFont(ofSize: 18))
        return CGSize(width: view.bounds.width, height: estimatedFrame.height + 20)
    }
}

 

* 전체 소스 코드: https://github.com/JK0369/ExChat

Comments