본문 바로가기

학습 노트/UIBreaking (2023)

AutoScrolling #02

화면 구성에 필요한 기본 인터페이스를 디자인한다.

사진을 표시할 ImageView들로 구성된 TabView와 해당 TabView와 연동되는 Tab Indicator를 구성한다.

ImageView

extension Home {
    func TabImageView(_ tab: Tab) -> some View {
        GeometryReader {
            let size = $0.size

            Image(tab.rawValue)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: size.width, height: size.height)
                .clipped()
        }
        .ignoresSafeArea(.container, edges: .bottom)
    }
}

표시할 이미지의 크기를 기기의 화면의 크기와 통일하기 위해 GeometryReader를 사용한다.
ImageView의 Frame은 서로 겹치지 않는 정확히 기기의 화면 크기만큼이 되고,
표시하는 사진은 이미지의 비율을 유지하며 채워 표시하고, ImageView의 Frame 외의 영역은 잘라낸다.

Tab마다 각각 다른 사진을 화면에 표시하기 때문에 TabView에서 호출할 때 현재 tab에 대한 정보를 파라미터로 전달하도록 구현했다.

struct Home: View {
    @State private var activeTab: Tab = .dance

    var body: some View {
        VStack(spacing: 0) {
            TabView(selection: $activeTab) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    TabImageView(tab)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .ignoresSafeArea(.container, edges: .bottom)
        }
    }
}

이후에 Tab Indicator와 연동하기 위해 현재 어떤 Tab이 선택됐는지를 저장할 필요가 있다.
이를 위한 State 변수인 activeTab을 정의하고, 최초로 표시할 Tab을 저장해 초기화한다.

TabView는 activeTab을 binding 형태로 전달받고,
이전에 정의한 Tab의 구조체 전체를 열거하며 TabImageView를 호출한다.

적용은 대충 된 것 같지만 몇 가지 문제가 보인다.

  • TabItem을 추가하지 않았기 때문에 어떤 Tab이 선택됐는지, 다음 Tab이 무엇인지 확인할 수가 없다.
  • Tab을 변경하기 위해 대충 지정된 곳을 누르면 바뀌긴 하지만 다른 Gesture 등의 방식을 지원하지 않는다.

TabItem은 이후에 Tab Indicator를 구현하는 것으로 해결한다고 치고, 좌우로 스와이프해 Tab을 이동할 수 있도록 코드를 수정한다.

struct Home: View {
    @State private var activeTab: Tab = .dance

    var body: some View {
        VStack(spacing: 0) {
            TabView(selection: $activeTab) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    TabImageView(tab)
                }
            }
            .tabViewStyle(.page)
        }
    }
}

TabView의 스타일을 page로 변경했다.
대신 기존에 ImageView에 적용했던 ignoreSafeArea가 적용이 되지 않은 것을 확인할 수 있다.

조금 더 수정해 필요 없는 Indicator를 제거하고, 빈 영역을 가득 채워보자.

struct Home: View {
    @State private var activeTab: Tab = .dance

    var body: some View {
        VStack(spacing: 0) {
            TabView(selection: $activeTab) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    TabImageView(tab)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .ignoresSafeArea(.container, edges: .bottom)
        }
    }
}

page 스타일의 indexDisplayMode에 never를 전달한다.
어떤 상황에서도 TabView의 Index을 표시하지 않는다.

ignoreSafeArea도 TabView에 다시 적용한다.
화면의 아래까지 사진이 가득 채운 것을 확인할 수 있다.

Tab Indicator

Tab Indicator를 별도로 구현하기 때문에 TabView와 연동하기 위한 State 변수가 하나 더 필요하다.

struct Home: View {
    @State private var activeTab: Tab = .dance
    @State private var scrollProgress: CGFloat = .zero

scrollProgress는 CGFloat 형식을 가지며, 초기값은 0.0이다.
해당 변수는 전체 스크롤 대비 얼마나 진행됐는지를 나타내며, 결과적으로 Tab Indicator가 어느 정도 반응해야 하는지에 대한 기준이 된다.

func TabIndicatorView() -> some View {
    GeometryReader {
        let size = $0.size
        let tabWidth = size.width / 3

        HStack(spacing: 0) {
            ForEach(Tab.allCases, id: \.rawValue) { tab in
                Text(tab.rawValue)
                    .font(.title3.bold())
                    .foregroundColor(activeTab == tab ? .primary : .gray)
                    .frame(width: tabWidth)
            }
        }
        .frame(width: CGFloat(Tab.allCases.count) * tabWidth)
        .padding(.leading, tabWidth)
        .offset(x: scrollProgress * tabWidth)
    }
    .frame(height: 50)
    .padding(.top, 15)
}

각각의 Indicator가 기기의 화면 크기에 맞춰 동적으로 알맞은 크기를 가질 수 있도록 GeometryReader를 사용해,
화면의 너비를 추출해 이를 3으로 나눠 한 화면에 최대 3개의 Indicator가 표시되도록 구현했다.

HStack에는 Tab 구조체의 모든 속성을 열거해 Text로 표시한다.
너비를 3분할로 표시할 것이기 때문에 Text의 frame은 tabWidth를 전달한다.
만약 Text가 표시하고 있는 tab이 activeTab과 동일하다면 primary로 표시하고, 외에는 회색으로 표시한다.

HStack의 전체 너비는 Tab의 모든 속성이 tabWidth만큼의 너비를 갖게 되므로, 둘을 곱한 만큼 할당하고,
첫 번째 표시되는 Tab은 화면의 중앙에 위치할 수 있도록 앞쪽에 tabWidth 만큼의 padding을 추가해 위치시킨다.
Tab의 개수보다 화면에 표시되는 Tab Indicator의 수가 적기 때문에, Tab이 이동하는 만큼 Tab Indicator의 위치도 함께 이동돼야 한다.
offset은 스크롤의 진행도를 나타내는 scrollProfgress와 각각의 Indicator의 너비를 사용해 이동 거리를 계산해 적용한다.

struct Home: View {
    @State private var activeTab: Tab = .dance
    @State private var scrollProgress: CGFloat = .zero

    var body: some View {
        VStack(spacing: 0) {
            TabIndicatorView()

            TabView(selection: $activeTab) {
                ForEach(Tab.allCases, id: \.rawValue) { tab in
                    TabImageView(tab)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .ignoresSafeArea(.container, edges: .bottom)
        }
    }
}

이렇게 만든 TabIndicator는 TabView의 상단에 위치시킨다.

결과는 위와 같다.

아직 애니메이션도 적용이 되지 않았고, activeTab을 변경하는 코드도 존재하지 않기 때문에
정상적인 Indicator의 기능을 수행할 수는 없지만 대략적인 모습이 갖춰졌다.

'학습 노트 > UIBreaking (2023)' 카테고리의 다른 글

AutoScrolling #05  (0) 2023.04.04
AutoScrolling #04  (0) 2023.03.23
AutoScrolling #03  (0) 2023.03.23
AutoScrolling #01  (0) 2023.03.16
AutoScrolling #00  (0) 2023.03.16