[UIScrollView] 상단 커스텀 탭바를 만들어봅시다! (3) - 특정 위치 자동 스크롤 기능 추가

2023. 12. 12. 21:03UIKit, SwiftUI, H.I.G

0️⃣ 이번 리팩토링의 목적!


어쩌다 보니 커스텀 탭바를 구현하면서 공부한 내용이 시리즈 형식으로 쓰이게 되었는데, 어느덧 세 번째 글을 쓸 차례이다.

사실 두 번째 글에서부터 이어진 내용이긴 한데,
이번 리팩토링은 각각의 뷰들이 커스텀 탭바의 각 탭들을 클릭했을 때 개별적으로 표출되도록 구현했던 화면을,
하나의 스크롤 뷰 위에 모두 포함시키고 그에 따른 커스텀 탭바의 자연스러운 액션 (스크롤 위치에 따라 탭바가 움직이는 기능 + 각 탭을 클릭했을 때 자동으로 해당 위치까지 스크롤되는 기능)까지 주도록 변경하는 것이 목적이었다.

이 리팩토링을 위해 앞에서 StickyHeader도 적용을 시켰던 것이었기에, 이번 글에서는 추가적으로 남은 탭바의 자연스러운 액션을 구현하도록 한 부분에 대한 설명을 이어서 써보도록 하겠다.

왼쪽은 각각의 탭으로써의 기능을 하는 탭바 (리팩 전), 오른쪽은 스크롤에 따른 자연스러운 액션으로 연결되는 탭바 (리팩 후)

 

1️⃣ SegmentedControl을 사용한 탭바에서 StackView와 UIButton으로 변경한 이유


 

[UIScrollView] 상단 커스텀 탭바를 만들어봅시다! (2) - StickyHeader 기능 추가

1️⃣ 오늘 구현할 기능, StickyHeader란 무엇일까? [UISegmentedControl] 상단 커스텀 탭바를 만들어봅시다! (1) - UISegmentedControl 활용 1️⃣ 오늘 만들어줄 화면은? 합동 세미나 과제로 테이블링 어플을 클

mini-min-dev.tistory.com

글 (1)에서는 커스텀 탭바를 만들기 위해 Segmented Control를 사용해서 만드는 과정을 차근차근 설명한 적이 있었는데, 이번 글에서는 그 부분을 UIStackView와 UIButton을 사용한 아주 단순한 방식으로 리팩토링하는 것부터 시작할 것이다.

왜냐고?
segmentedControl의 각 index를 클릭했을 때 발생하는 target은 .valueChanged.
segmentedControl의 값이 변경되었을 때에 target이 반응하는 함수를 연결해주는 방식으로 구현했었다. (위 커탭 시리즈 글 #1 참조)

segmentControl.addTarget(self, action: #selector(didChangeValue(_:)), for: .valueChanged)

그러다 보니 이런 상황에서는, 이미 탭이 선택되어 있는 상태에서 다시 클릭을 했을 때 반응을 하지 않는다는 문제가 발생하게 되었다.

다시 말해, 아래 화면과 같이 홈/전체메뉴/최근리뷰 라는 탭의 영역이 이미 활성화(클릭)되어 있을 때, 다시 현재 상태에서 홈/전체메뉴/최근리뷰 같은 활성화되어 있는 탭을 재클릭하더라도 해당 부분으로 자동 스크롤이 돼야 하는 기능을 구현할 수가 없었다는 뜻이다.
아래와 같은 기능을 segmentedControl을 사용했을 때는 구현할 방법이 없었다.

전체 메뉴 활성화 탭 -> 다시 전체 메뉴 재클릭 -> 스크롤 동작

그래서 커스텀했던 SegmentedControl은 세 개의 개별 UIButton으로, 그리고 이 세 버튼을 묶어줄 하나의 StackView로 만들어주었고,

SegmentedControl의 .valueChanged 타겟을 주는 방식이 아니라, UIButton의 .touchUpInside 타겟을 주는 방식으로 코드를 변경했다. (.touchUpInside는 버튼의 활성화 상태와는 상관없이 사용자의 터치업 액션 자체에 반응하는 분기이다.)

    func setAddTarget() {
        homeButton.addTarget(self, action: #selector(isTabButtonClicked), for: .touchUpInside)
        allMenuButton.addTarget(self, action: #selector(isTabButtonClicked), for: .touchUpInside)
        recentReviewButton.addTarget(self, action: #selector(isTabButtonClicked), for: .touchUpInside)
    }

 

2️⃣ 현재 스크롤 뷰의 높이에 따라 버튼 색 & 탭바의 위치가 바뀌도록 하자


SegmentedControl이 아니라 UIButton으로 바뀐 이상,
.normal 상태와 .selected 상태에 따라 다른 상태를 구분해 준 부분도 .normal 상태와 .highlighted 상태에 따라 다른 상태를 구분해주는 것으로 변경했다.

💡 UIButton에도 .selected state가 존재하는데, .highlighted state로 변경해서 사용한 이유가 뭐죠?

1️⃣ .normal state : 말 그대로 기본 상태, 사용자와 Control 요소 사이 아무런 상호 작용이 일어나지 않은 상황
2️⃣ .selected state : 해당 Control이 선택되어 있는 상태가 계속 유지되어 있는 상황 -> Toggle이나 Segmented Control 같이 사용자와 Control 요소 사이에 일어난 상호 작용의 결과가 Contol 요소에 반영되어야 할 때 사용하는 상태
3️⃣ .highlighted state : 사용자가 Control 요소에 대해 터치나 클릭을 시작했지만, 아직 손을 떼지 않은 상황 -> 버튼을 누르고 있을 때 (touchDown 상태)는 버튼이 .highlighted 상태로 인식되고, 손을 뗄 때(touchUp 상태)는 다시 .normal 상태로 돌아간다. (중복해서 인식하는 것이 가능!)
    private let homeButton: UIButton = {
        let button = UIButton()
        button.isHighlighted = true
        button.setTitle(I18N.StoreDetail.homeSegmentControlTitle, for: .normal)
        button.titleLabel?.setLineAndCharacterSpacing(font: .pretendardSemiBold(size: 16))
        button.setTitleColor(.Gray200, for: .normal)
        button.setTitleColor(.Gray800, for: .highlighted)
        return button
    }()
    
    ... 다른 버튼도 이와 동일

즉 이렇게 해줌으로써, button의 isHighlighted 속성을 true로 하느냐, false로 하느냐에 따라 버튼 라벨의 색상을 다르게 표출할 수 있게 된 셈이다.
탭바 상태에 따라 3개 버튼 각각의 isHighlighted 속성을 변경해주는 함수도 아래와 같이 구현해줬다.

    func changeCustomHeader(index: Int) {
        changeSegmentedControlLinePosition(selectedSegmentIndex: index)
        switch index {
        case 0:
            homeButton.isHighlighted = true
            allMenuButton.isHighlighted = false
            recentReviewButton.isHighlighted = false
        case 1:
            homeButton.isHighlighted = false
            allMenuButton.isHighlighted = true
            recentReviewButton.isHighlighted = false
        default:
            homeButton.isHighlighted = false
            allMenuButton.isHighlighted = false
            recentReviewButton.isHighlighted = true
        }
    }
    
    /// 탭바 밑에 따라 오는 underLine도 같이 움직이도록 함수를 구현
    func changeSegmentedControlLinePosition(selectedSegmentIndex: Int) {
        let leadingDistance = Int(95 * CGFloat(selectedSegmentIndex) + (95 - self.underLineView.bounds.width) * 0.5)
        UIView.animate(withDuration: 0.2, animations: {
            self.underLineView.snp.updateConstraints {
                $0.leading.equalTo(self.stackView.snp.leading).offset(leadingDistance) }
            self.layoutIfNeeded()
        })
    }

 

3️⃣ 현재 스크롤 뷰의 y값을 어떻게 가져온다고? scrollViewDidScroll( )


자 이제 첫째로, 현재 스크롤 뷰의 y값에 따라 탭바가 자동으로 움직이도록 하는 기능을 추가해 보겠다.

스크롤 뷰 y값의 범위는 홈 (홈뷰 y값 ~ 홈뷰 y값+홈뷰 높이값), 전체메뉴 (홈뷰 y값+홈뷰 높이값 ~ 전체메뉴 y값+전체메뉴 높이값), 최근리뷰 (전체메뉴 y값+전체메뉴 높이값 ~ 마지막) 크게 3개로 겹치는 부분 없이 나누었고,
스크롤 뷰의 현재 y값을 가져오기 위해 지난 앞선 글 #2에서 사용했던 방식인 UIScrollView의 delegate 메서드인 scrollViewDidScroll를 사용해 분기처리를 해주었다.
*복습! scrollViewDidScroll은 UIScrollView가 스크롤이 발생할 때 수행하는 작업을 작성할 수 있는 델리게이트 메서드다.

이때 꼭 중요한 점이 있다면, 일반 헤더와 StickyHeader 이렇게 2개의 탭을 사용하는 셈인 내 방식에서 2개의 탭바에 모두 함수를 적용시켜줘야 한다는 것이다.

// MARK: - UIScrollView Delegate
extension StoreDetailViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let yOffset = scrollView.contentOffset.y
        
        /// 스크롤에 따른 커스텀 헤더 이벤트 변경
        if yOffset >= storeDetailView.homeView.frame.origin.y && yOffset < storeDetailView.homeView.frame.origin.y+storeDetailView.homeView.frame.height {
            normalCustomTabBarHeaderView.changeCustomHeader(index: 0)
            stickyCustomTabBarHeaderView.changeCustomHeader(index: 0)
        } else if yOffset >= storeDetailView.homeView.frame.origin.y+storeDetailView.homeView.frame.height && yOffset < storeDetailView.allMenuView.frame.origin.y+storeDetailView.allMenuView.frame.height {
            normalCustomTabBarHeaderView.changeCustomHeader(index: 1)
            stickyCustomTabBarHeaderView.changeCustomHeader(index: 1)
        } else if yOffset >= storeDetailView.allMenuView.frame.origin.y+storeDetailView.allMenuView.frame.height {
            normalCustomTabBarHeaderView.changeCustomHeader(index: 2)
            stickyCustomTabBarHeaderView.changeCustomHeader(index: 2)
        }
    }
}

이제 위와 같이 처리해 주면서,
스크롤이 홈 영역에 있을 때는 홈 버튼의 색상이 바뀌고 underLine도 홈 탭 밑에 위치하게 되고, 전체메뉴 영역에 있을 때도, 최근리뷰 영역에 있을 때도 동일하게 처리되는 것을 확인할 수 있었다.

 

4️⃣ 탭바 버튼을 클릭하면 내가 원하는 위치까지 자동 스크롤되는 기능까지 넣어주자!


이제 마지막으로 커스텀 탭바의 각 탭(버튼) 영역을 클릭했을 때, 스크롤 뷰의 특정 지점까지 자동으로 스크롤되는 기능만 추가해 주면 된다.
자동 스크롤도 마찬가지로 스크롤이기 때문에, delegate가 자동으로 호출될 것이기 때문이다.

1) 사용자가 스크롤 뷰를 직접 스크롤했을 때
스크롤이 되었으니 scrollViewDidScroll 메서드가 호출 -> 탭 버튼의 isHighlighted 속성이 변경되면서 underLine도 같이 해당 부분으로 이동 -> 탭 라벨의 색상이 highlighted 속성일 때 지정해 준 것으로 변경

2) 탭바의 버튼을 눌러서 스크롤 뷰가 자동 스크롤이 되었을 때
사용자가 탭바의 탭 버튼을 클릭 -> 특정 부분까지 자동 스크롤 -> 스크롤이 되었으니 scrollViewDidScroll 메서드가 자동으로 호출 -> 탭 버튼의 isHighlighted 속성이 변경되면서 underLine도 같이 해당 부분으로 이동 -> 탭 라벨의 색상이 highlighted 속성일 때 지정해 준 것으로 변경

스크롤 뷰의 콘텐츠를 특정한 위치까지 이동(자동 스크롤)시키기 위해서는 scrollView.setContentOffset(_ contentOffset: CGPoint, animated: Bool) 함수를 사용할 수 있다.

contentsOffset 매개변수로 내가 스크롤하고 싶은 위치를 CGPoint 형태(콘텐츠의 x, y값)로 입력하고, animation 유무를 선택하기만 하면 완성!

extension StoreDetailViewController: CustomTabBarHeaderViewDelegate {
    func firstSegmentClicked() {
        scrollView.setContentOffset(CGPoint(x: 0, y: storeDetailView.firstGrayView.frame.origin.y), animated: true)
    }
    
    func secondSegmentClicked() {
        scrollView.setContentOffset(CGPoint(x: 0, y: storeDetailView.secondGrayView.frame.origin.y), animated: true)
    }
    
    func thirdSegmentClicked() {
        scrollView.setContentOffset(CGPoint(x: 0, y: storeDetailView.thirdGrayView.frame.origin.y), animated: true)
    }
}

모두 적용된 최종 화면이다!