본문 바로가기
iOS/구현

[SwiftUI] TabView page indicator 커스텀하기

by Callie_ 2024. 4. 25.

 

 

 

 

 

프로젝트 진행 중 내가 맡은 ui에서 슬라이드뷰를 만들어야 했는데, 

 

 

 

피그마에 올라와 있는 디자인 가이드가 위 이미지와 같았다. 따라서 SwiftUI에서 Tabview가 제공해주는 원형 인디케이터를 사용할 수가 없었고, custom 형식으로 직접 구현해야했다.

 

 

 

 

내가 구현해야 하는 것:

 

1. 이미지 슬라이더

2. 일정시간 간격을 두고 슬라이더의 이미지가 바뀌어야 함

3. 이미지가 바뀔 때, 해당 이미지 순서의 인디케이터 모양이 원형에서 가로로 긴 타원형으로 변경되어야 함

 

 

 

 

 

그래서

 

https://www.youtube.com/watch?v=uo8gj7RT3H8

 

 

위 영상을 참고하여 코드를 작성했었다.

iOS 15기준으로 위 영상의 코드로 구현을 하면 index가 0에서 1을 넘어갈 경우 외에는 슬라이더 기능이 되지 않았다.

 

iOS 15이후 부터 발생하는 문제로, 영상 속 코드는 슬라이드가 넘어가면 인덱스가 인식 되지 않고 사라지는 게 기능 불능의 원인이었다.

 

 

 

 

 

 

 

해결 방법:

 

- 슬라이드 배열의 인덱스를 인식하지 못 하고 있는 게 문제였기 때문에, 인덱스를 인식 시켜주고, 그 인덱스 기준으로 슬라이드 이미지 교체 + 인디케이터 형태 custom 해주는 식으로 방향을 잡았다.

 

- 그래서 (1) currentIndext에 현재 페이지 인덱스를 저장하는 @state 형식으로 선언해주고, (2) currentIndex를 기준으로 오프셋 변경 여부를 구하고, (3) tag로 확실하게 저장하고, (4) 인디케이터 형태를 그에 맞게 바꿔줬다.

 

 

 

 

아래는 영상을 기반으로 작성한 코드를 개선한 코드이자 내가 원한 UI 및 기능 구현을 한 코드다.

기타 설정 등은 주석 참고.

 

import SwiftUI

struct MainEventPopupSlidesView: View {
    
    // 타이머 설정
    private let timer = Timer.publish(
        every: 3,
        on: .main,
        in: .common
    ).autoconnect()
    
    // 이미지 배열
    private let images: [String] = [
        "bottle_coffee1",
        "bottle_coffee2",
        "bottle_coffee3"
    ]
    
    @State var offset: CGFloat = 0 // 현재 슬라이드 오프셋 저장
    @State private var currentIndex = 0 //현재 페이지 인덱스
    
    var body: some View {
        
        ScrollView(.init()){
            
            TabView(selection: $currentIndex){ // 현재 선택 항목 추적용
                
                ForEach(images.indices, id: \.self) { index in
                    
                    if index == 0 {
                        
                        // 오프셋 구하기용
                        Image(images[index])
                            .resizable()
                            .scaledToFill()
                            .overlay(
                                
                                // offset 계산
                                GeometryReader { proxy -> Color in
                                    
                                    let minX = proxy.frame(in: .global).minX
                                    
                                    DispatchQueue.main.async {
                                        withAnimation(.default) {
                                            self.offset = -minX
                                        }
                                    }
                                    
                                    return Color.clear
                                }
                                    .frame(width: 0, height: 0)
                                , alignment: .leading
                            )
                            .tag(index)
                        
                    } else {
                        
                        Image(images[index])
                            .resizable()
                            .scaledToFill()
                            .tag(index)
                        
                    }
                }
                
            }
            .tabViewStyle(
                PageTabViewStyle(
                    indexDisplayMode: .never
                )
            )
            
            // 페이지 인디케이터
            .overlay(
                
                HStack(spacing: 4) {
                    
                    ForEach(images.indices, id: \.self) { index in
                        
                        Capsule()
                        
                        // 캡슐 디자인
                        // 선택된 페이지와 일치하는 경우 채우기 색 설정
                            .stroke(.uiColorGray350, lineWidth: 1)
                            
                            // 선택 인덱스 기준 크기 변경
                            .frame(width: getIndex() == index ? 16 : 6, height: 6)
                            //선택 인덱스 따라 투명도 변경
                            .opacity(getIndex() == index ? 1 : 0.5)
                            
                            .background(getIndex() == index ? .uiColorGray350 : Color.clear)
                            .cornerRadius(3, corners: .allCorners)
                        
                        
                    }
                    
                }
                    .padding(.bottom, 24), alignment: .bottom
                
            )
            
        }
        .ignoresSafeArea()
        
        // 페이지 변경 될 때 오프셋 업데이트
        .onChange(of: currentIndex) { newIndex in
            let newOffset = CGFloat(newIndex) * getWidth()
            withAnimation {
                offset = newOffset
            }
        }
        
        // 타이머 설정
        .onReceive(timer) { _ in
            let newIndex = currentIndex < images.count - 1 ? currentIndex + 1 : 0
            currentIndex = newIndex
        }
    }
    
    /// 현재 페이지의 인덱스를 가져옴
    func getIndex() -> Int {
        
        let index = Int(round(Double(offset / getWidth())))
        return index
        
    }
    
    /// 오프셋 값 가져옴
    func getOffset() -> CGFloat {
        
        let progress = offset / getWidth()
        return 22 * progress
        
    }
    
}


extension View {
    
    /// 화면 너비 가져옴
    func getWidth() -> CGFloat {
        return UIScreen.main.bounds.width
    }
    
}

 

 

 

결과물 (이미지 캡처):

 

 

캡처한 이미지 형태로 슬라이드가 잘 작동되는 걸 확인할 수 있다.

 

 

 

 

 

 

References

 

https://seons-dev.tistory.com/entry/SwiftUI-TabView

 

SwiftUI : TabView / TabViewStyle

목차 TabView 에 대해 알아보도록 합시다. TabView SwiftUI는 UIKit의 UITabBarController와 동등한 기능을 가진 TabView를 제공합니다. 사용자가 화면 하단의 막대를 사용하여 여러 View를 전환할 수 있습니다.

seons-dev.tistory.com