애플사이다의 iOS 개발 일지

[채팅 기능] Stretchable Image로 채팅 버블 구현하기 (1/5) 본문

iOS

[채팅 기능] Stretchable Image로 채팅 버블 구현하기 (1/5)

Applecider 2023. 5. 7. 02:10

최근 9개월간 국내 제품을 해외 45개국에 판매하는 글로벌 프로젝트에 참여해 왔다.

2023년 2월에 MVP 버전의 앱을 출시했고, 그 이후 앱을 고도화하는 작업을 진행하고 있다.

 

최근 가장 전념하고 있는 건 판매자와 구매자 간의 채팅 기능을 개발하는 것이다.

Socket 통신, Polling, Stomp 등 처음 접하게 된 네트워크 개념도 있었고,

글로벌 서비스라서 고민해 볼 수 있는 TimeZone, Locale, DateFormat 처리, 

채팅 목록과 채팅 상세 화면의 복잡한 UI 구현, Up Scrolling 등 

쉽지 않지만 흥미진진한 문제들을 맞닥뜨리며, 단기간에 경험치를 쑥쑥 올리고 있다.

 

이렇게 채팅 기능을 구현하면서 학습한 아래의 5개 주제를 시리즈 형식으로 포스팅해보려 한다.

 

1. Stretchable Image - 채팅 버블 구현하기

2. ScrollView의 inset, scroll 버그 (부제: Known issue인줄 알았다면 삽질을 안했을텐데)

3. TimeZone/Locale/Calendar - 국가별 시간 표시, 채팅 버블 배치

4. Up Scroll Pagination - CGAffineTransform으로 CollectionView와 cell 뒤집기

5. Socket/Polling/Stomp 이해하기 - 양방향 네트워크 구현


이번 포스팅에서는 첫번째 주제인 Stretchable Image를 다룬다.

Introduction

채팅 상세 화면에서 가장 기본적인 UI는 "채팅 버블"이다.

채팅 버블이라는 네이밍에서 유추 가능하듯이 말풍선 모양으로 묶인 1개의 메시지를 의미한다.

 

이 채팅 버블을 구현해보자.

평소 카카오톡을 사용할 때 채팅방의 디자인을 눈여겨본 적이 있다면

텍스트가 길어질수록 채팅 버블의 크기 (너비/높이)도 커진다는 것을 알고 있을 것이다.

채팅 상세화면 (카톡 / 예시)

문제점

이 버블 UI를 어떻게 구현할까?

일단 짐작가는 대로 화면을 그려보자.

말풍선을 쌓아나가는 형태이므로 CollectionView를 만들어 말풍선 1개를 Cell로 구성하고,

Cell 위에 말풍선 imageView를 올리고,
말풍선 imageView의 subView로 message Label, translation button을 올리면 된다.

 

그리고 빌드해보면?

언뜻보면 잘 그려진 것 같지만 문제가 있다.

버블이 커질수록 말풍선의 모서리가 점점 둥글어져서 못생겨지는 것이다! (cornerRadius 값이 커지기 때문이다.)

이미지가 커질수록 모서리가 둥글어짐

이때 이미지의 확대 영역을 설정할 수 있는 Stretchable Image가 필요하다.

참고로 안드로이드에서는 9-Patch 이미지라고 부른다.

Stretchable Image란?

Stretchable Image를 직역하면 "늘어날 수 있는 이미지"이다.

Stretchable Image를 사용하면 이미지 크기를 키울 때, 이미지의 특정 영역만 늘어나도록 설정할 수 있다.

 

아래 그림으로 비교해보자.

일반적인 이미지의 크기를 키우면 이미지 전체가 일괄적으로 확대되지만 (왼쪽)

Stretchable Image모서리를 제외한 중앙 영역만 늘어난다. (오른쪽)

 

왼쪽: 일반 이미지 / 오른쪽: Stretchable Image

 

아래처럼 꼬리가 있는 말풍선 그림으로 보면 쉽게 이해할 수 있다.

1: 이미지 원본 / 2: 일반 이미지 / 3: Stretchable Image

공식문서 UIImage - Define a stretchable image에 그림과 함께 개념이 설명되어 있다.

아래 그림처럼 inset을 설정하면, 모서리 영역은 늘어나지 않도록 고정할 수 있다.

 

정확히는 inset으로 인해 이미지가 9개 영역으로 구분되며, 각각 수평/수직 방향으로 다르게 변형된다.

예를 들어 Top/Bottom inset 영역은 높이는 고정되고, 너비는 늘어난다.

반대로 Left/Right inset 영역은 너비는 고정되고, 높이는 늘어난다.

 

Stretchable Image 구현하기

Stretchable Image를 만드는 방법은 2가지가 있다.

하나는 Asset의 Slicing을 사용하는 것이고, 다른 하나는 코드로 구현하는 것이다.

방법-1. Asset Slicing 활용

이 방법은 이미지 위에 직접 확대 영역을 표시할 수 있어서 간편하다.

그리고 코드로 구현하지 않아도 이미지를 재사용 가능하므로 개인적으로 이 방법을 선호한다.

 

Asset에서 이미지를 선택하고, 우상단의 Show Slicing을 클릭한다.

(이번 예시에서는 2x, 3x png 파일을 사용했다.)

그 다음 이미지 위의 Start Slicing을 클릭하고, 확대 가능 영역의 방향 (수평, 수직&수평, 수직)을 선택한다.

채팅 버블은 너비와 높이 모두 늘어나야 하므로 수직&수평을 선택했다. 

우하단의 Slicing 탭에서 Slices와 Center를 각각 설정한다.

(위에서 수직&수평을 선택했다면 Slices는 자동으로 입력된다.)

  • Slices : Horizontal and Vertical = 너비, 높이 둘다 늘어나게 할거임
    (Horizontal = 너비만 늘어나게 할거임,
    None = Strechtable Image 안쓸거임)
  • Center : Strectches = 늘린 공간은 이미지를 확대해서 채울거임
    (Tiles = 늘린 공간을 타일 형태로 똑같은 이미지를 여러 개 복붙하는 방식으로 채울거임)

그리고 이미지 위에 확대 가능한 영역을 지정해주면 끝이다!

아래 화살표로 표시한 영역이다.

모서리 부분은 선택하지 않았기 때문에 이제 늘어나지 않는다.

참고로 궁금해서 실험해봤는데, 아래처럼 Slicing을 사용하되 확대 가능 영역을 지정하지 않으면 어떻게 될까?

버블이 원본-선 형태로 깨진다.

방법-2. resizableImage(withCapInsets:resizingMode:) 활용

코드로 구현하는 방법도 구조는 동일하다.

  • capInsets = 확대하지 않을 영역 (ex. 모서리)을 지정함
  • resizingMode = 늘어난 영역을 어떻게 채울건지 (Stretches인지 Tiles인지)

Cap insets에는 어떤 값을 넣어야 할까?

예를 들어, Asset의 원본 이미지가 72x72 pixel이고, 모서리의 둥글게 처리된 부분이 10 pixel이라면

inset은 10으로 설정하면 된다.

 

이때 2x, 3x png 파일을 모두 사용한다면 inset에 고정값을 넣는 것보다 

이미지 크기의 특정 비율을 할당하는 방법이 더 낫다.

예를 들어 left/right inset에는 image.size.width * 0.4, top/bottom inset에는 image.size.height * 0.4를 할당하면 된다.

 

참고로 여기서 조금 헷갈릴 수 있는데, inset에 width * 0.5, height * 0.5를 할당하면 이미지가 안 늘어날까?

답은 "늘어난다"이다.

cap inset은 이미지의 원본 크기 (ex. 72x72 pixel)를 기준으로 설정한 것이기 때문에

이미지를 키우면 cap 영역으로 설정되지 않은 중앙 부분이 늘어나게 된다.

private let bubbleImageView: UIImageView = {
    let imageView = UIImageView()
    imageView.tintColor = .systemGray6
    imageView.isUserInteractionEnabled = true
    
    // 방법-1. inset에 고정값 할당
    let inset = 10.0
    imageView.image = UIImage(named: "bubble")?.resizableImage(withCapInsets: UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset), resizingMode: .stretch).template
    
    // 방법-2. inset에 이미지 비율 할당
    let image = UIImage(named: "bubble")
    let horizontalInset = (image?.size.width ?? 0.0) * 0.4
    let verticalInset = (image?.size.height ?? 0.0) * 0.4   
    imageView.image = image?.resizableImage(withCapInsets: UIEdgeInsets(top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset), resizingMode: .stretch).template

    return imageView
}()

주의할 점

삽질하면서 하나 알아낸 것이 있다.

Asset에 넣을 이미지는 가장 최소 크기로 준비해야 한다는 점이다.

Stretchable Image는 확대는 가능하지만, 축소는 불가능하기 때문이다. 

심지어 공식문서에 "Stretchable images are commonly used to create backgrounds that can grow or shrink to fill the available space." 라고 명시되어 있는데 실제로 해보면 줄어들지 않는다... 애플놈들 부들부들 😂

 

예를 들어 위에서 본 Short one 텍스트 버블이 가장 작은 크기였는데

만약 준비한 Asset 이미지의 크기가 더 크다면?

버블이 줄어들지 않으므로 버블 모서리가 뾰족하게 보일 수 있다. (의도한 cornerRadius보다 작아진 상태)

 

이렇게 채팅 버블 이미지처럼 이미지 크기를 늘릴 때 특정 영역만 확대되도록 설정하고 싶다면

Stretchable Image를 사용해보자!


예제 코드

private let bubbleImageView: UIImageView = {
    let imageView = UIImageView()
    imageView.tintColor = .systemGray6
    // ✅imageView의 subView로 label/button을 올리므로
    // tap gesture를 처리하려면 isUserInteractionEnabled 설정이 필요함
    imageView.isUserInteractionEnabled = true 

    let image = UIImage(named: "bubble")
    let horizontalInset = (image?.size.width ?? 0.0) * 0.4
    let verticalInset = (image?.size.height ?? 0.0) * 0.4   
    // ✅resizableImage 활용
    imageView.image = image?.resizableImage(withCapInsets: UIEdgeInsets(top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset), resizingMode: .stretch).template
    return imageView
}()
private let msgLabel: UILabel = {
    let label = UILabel()
    label.numberOfLines = 0
    label.setContentCompressionResistancePriority(.required, for: .vertical)
    label.setContentHuggingPriority(.required, for: .vertical)
    return label
}()
private let translatedMsgLabel: UILabel = {
    let label = UILabel()
    label.numberOfLines = 0
    return label
}()
private lazy var translationButton: UIButton = {
    let button = UIButton()
    button.setTitle("See Translation".localized, for: .normal)
    button.setTitle("Hide Translation".localized, for: .selected)
    button.setTitleColor(UIColor.systemGrey, for: .normal)
    button.setTitleColor(UIColor.systemGrey, for: .selected)
    button.titleLabel?.font = .systemFont(ofSize: 12)
    button.addAction(UIAction { [weak self] _ in
        self?.translationButtonTapped()
    }, for: .touchUpInside)
    return button
}()

// ...

private func makeLayout() {
    addSubview(bubbleImageView)
    let leadingOffset = Const.horizontalPadding + Const.artistThumbnailViewSize + Const.artistThumbnailViewRightMargin
    bubbleImageView.snp.makeConstraints { make in
        make.top.equalToSuperview()
        make.leading.equalToSuperview().offset(leadingOffset)
        make.bottom.equalToSuperview().inset(Const.bottomPadding)
    }

    bubbleImageView.addSubview(msgLabel)
    msgLabel.snp.makeConstraints { make in
        make.top.equalToSuperview().offset(Const.contentPadding.top)
        make.leading.equalToSuperview().offset(Const.contentPadding.left)
        make.trailing.lessThanOrEqualToSuperview().inset(Const.contentPadding.right)
    }

    bubbleImageView.addSubview(translatedMsgLabel)
    originalMsgLabel.snp.makeConstraints { make in
        make.top.equalTo(msgLabel.snp.bottom)
        make.leading.equalToSuperview().offset(Const.contentPadding.left)
        make.trailing.lessThanOrEqualToSuperview().inset(Const.contentPadding.right)
    }

    bubbleImageView.addSubview(translationButton)
    translationButton.snp.makeConstraints { make in
        make.top.equalTo(translatedMsgLabel.snp.bottom)
        make.leading.greaterThanOrEqualTo(msgLabel)
        make.bottom.equalToSuperview().inset(Const.contentPadding.bottom)
        make.height.equalTo(Const.originalButtonHeight)
    }
    translationButton.titleLabel?.snp.makeConstraints { make in
        make.leading.equalTo(msgLabel.snp.leading)
        make.trailing.equalTo(bubbleImageView).inset(Const.contentPadding.right) 
    }   
}

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

 

 

Comments