SwiftUI

SwiftUI | HeaderView(Sticky View)만들기전 알아야할 기본 상식 정의 + 만들기 - 1

ziziDev 2024. 9. 19. 23:43
반응형

 

상단에 이미지를 고정 설정

pinnedViews

 

스크롤 시에도 화면 상단이나 하단에 고정시킬 수 있도록 도와주는 구조체 타입형태

 

 

pinnedViews는 SwiftUI에서 사용되는

LazyVStack / LazyHStack에서 특정 Header / Footer 스크롤 상단이나 하단에 고정시켜주는 기능

 

pinnedViews 역할

섹션 헤더가 스크롤에 따라 사라지지 않고 리스트 상단에 고정되도록하는데 사용이 됩니다

설정 앱이나 연락처 앱처럼 카테고리별 섹션이 고정되는 효과를 줄 수 있습니다

 

 

LazyVStack(pinnedViews: [.sectionHeaders]) {
    Section {
        // 섹션의 콘텐츠
    } header: {
        // 고정할 헤더 뷰
    }
}

 

 

우선 pinnedViews에 대해서

코드로 보는게 더 정확하기 때문에 예시를 하나 알아봅시다

 

 

import SwiftUI

struct ColorData: Identifiable {
    let id = UUID()
    let name: String
    let color: Color
    let variations: [ShadeData]


    struct ShadeData: Identifiable {
        let id = UUID()
        var brightness: Double
    }


    init(color: Color, name: String) {
        self.name = name
        self.color = color
        //0.0-0.5 0.1씩 증가하는 밝기 생성
        self.variations = stride(from: 0.0, to: 0.5, by: 0.1)
            .map { ShadeData(brightness: $0) }
    }
}

 

import SwiftUI

struct ColorSelectionView: View {
    let sections = [
        ColorData(color: .red, name: "Reds"),
        ColorData(color: .green, name: "Greens"),
        ColorData(color: .blue, name: "Blues")
    ]
    
    
    var body: some View {
        ScrollView {
            //LazyVStack(spacing: 1) {
            LazyVStack(spacing: 1, pinnedViews: [.sectionHeaders]) {
                //각 섹션 반복
                ForEach(sections) { section in
                    //section header와 content
                    Section(header: SectionHeaderView(colorData: section)) {
                        ForEach(section.variations) { variation in
                            section.color
                                .brightness(variation.brightness)
                                .frame(height: 20)
                        }
                    }
                }
            }
        }
    }
}

struct SectionHeaderView: View {
    var colorData: ColorData


    var body: some View {
        HStack {
            Text(colorData.name)
                .font(.headline)
                .foregroundColor(colorData.color)
            Spacer()
        }
        .padding()
        .background(.black)
    }
}

#Preview {
    ColorSelectionView()
}

 

근데 여기서 pinnedViews는 LazyVStack, LazyHStack에서만 사용이 가능합니다

 

Lazy Container 특성은 콘텐츠를 메모리를 효율적으로 렌더링하기 위해서

스크롤이 필요할 때만 뷰를 로드하는 방식으로 작동하기 때문에

리스트가 길어질 수록 메모리 사용량과 렌더링 성능을 개선할 수 있고

header, foooter view는 Lazy특성을 사용해서 구현이 됩니다

 

섹션별로 구분되어있는 뷰 계층 구조를 가지고 있기 때문에

header, footer로 고정하는게 가능하고

반면에 VStack, HStack은 모든 하위뷰에 한 번에 렌더링해서

Section과 같은 구조적 구분이 없기 때문에 스크롤 시 헤더를 고정하는 것과 같은 동작을

구현하기엔 어려움이 있습니다

 

스크롤 성능을 최적화하기 위해서 PinnedViews는 스크롤할 때 헤더나 푸터를 고정시키는 동작을 하는데

스크롤 이벤트를 지속적으로 감지하고 뷰의 위치를 동적으로 변경해야하는데 이 과정에서

Lazy Container는 스크롤 위치와 관련된 정볼르 활용하여 효율적으로 뷰를 업데이트할 수 있지만

VStack, HStack은 모든 하위 뷰를 한 번에 렌더링 하기 때문에 성능상 불리합니다

 

그 다음으로 알아야 하는 개념은

GeometryReader

 

뷰가 자신의 크기와 좌표 공간에 따라 콘텐츠를 정의할 수 있도록 해주는 컨테이너 뷰라고 하고 있습니다

GeometryReader를 사용하면 뷰의 부모 컨테이너의 크기와 위치 정보를 얻을 수 있고 자식 뷰의 레이아웃을 동적으로 조절이 가능합니다

 

 

 

 

import SwiftUI

struct GMRView: View {
    var body: some View {
        NavigationStack {
            VStack {
                Rectangle()
                    .fill(.green)
                
                Group {
                    Text("Hello")
                        .background(.yellow)
                    Text("Goodbye")
                        .background(.purple)
                }
                .frame(width: 100, height: 100, alignment: .leading)
                
                //geometry는 사용 가능한 공간이 있다면 동일한 사이즈를 가지게끔 화면에 할당해줌
                //현재 Rectengle, Group, GeometryReader 총 3개의 section으로
                //나누기 때문에 3등분 한 것을 볼 수 있다
                GeometryReader { proxy in
                    Rectangle()
                        .fill(Color.brown)
                    //다른 컨테이너 뷰와 달리 left,top 기준으로 그려진다(alignment 무시)
                    //기본적으로 지오메트리 공간에서 좌표가 0,0 상단 앞 가장자리 원점에 배치하게됨
                    Rectangle()
                        .fill(Color.cyan)
                        .frame(width: 100, height: 100, alignment: .center)
                    Text("Where am I?")
                }
                .background(.blue)
                //==============================4등분
                GeometryReader { proxy in
                    Text("Where am I")
                        .position(x: 0,
                                  y: 0)
                    VStack(alignment: .leading) {
                       
//                        Text("\(proxy.frame(in: .local).debugDescription)")
//                        Text("\(proxy.frame(in: .global).debugDescription)")
//                        Text("\(proxy.frame(in: .named("VStack")).debugDescription)")
                        Text(proxy.info(space: .local))
                        Text(proxy.info(space: .global))
                        Text(proxy.info(space: .named("VStack")))
                        
                    }
                    Text("center")
                        .position(x: proxy.frame(in: .local).midX,
                                  y: proxy.frame(in: .local).midY)
                }
            }
            .coordinateSpace(name: "VStack")
            .background(.teal)
            .navigationTitle("GeometryReader")
        }
    }
}

#Preview {
    GMRView()
}

import SwiftUI

struct GMRView2: View {
    var body: some View {
        NavigationStack {
            VStack {
                Rectangle()
                    .fill(.green)
                    .frame(height:200)
                    .overlay {
                        Text("VStack Offset '208'")
                            .font(.title)
                            .foregroundStyle(.purple)
                            .fontWeight(.bold)
                    }
                GeometryReader { proxy in
                    VStack(alignment: .leading) {
                        Text(proxy.info(space: .local))
                        Text(proxy.info(space: .global))
                        Text(proxy.info(space: .named("VStack")))
                    }
                    Text("midY '480 / 2 = '240'\n GlobalY Postion : 360 + 208 + 240 = 808 ")
                        .position(x: proxy.frame(in: .local).midX,
                                  y: proxy.frame(in: .local).midY)
                        .font(.title3)
                        .foregroundStyle(.purple)
                        .fontWeight(.bold)
                    
                    
                    //position global Space
                    Text("VStack GlobalY")
                        .position(x: proxy.frame(in: .local).midX,
                                  y: proxy.frame(in: .named("VStack")).midY)
                }
            }
            //상대적 위치나 크기를 효과적으로 계산 가능
            //좌표공간을 지정하고 하위 뷰가 이 좌표 공간을 참조할 수 있게함
            .coordinateSpace(name: "VStack")
            .background(.teal)
            .navigationTitle("GeometryReader")
        }
    }
}

#Preview {
    GMRView2()
}



 

화면이 여러 층으로 이루어져 있는데 화면의 위에 NavigationBar+Status Bar + Rectangle = 360(global Position)

GeometryReader의 시작위치는 VStack 내부에 Rectangle 208 아래에 위치하고 있습니다

그래서 240 + 208 = 448 + 360 = 808

 

import SwiftUI

struct ImageGridView: View {
    @State private var numImages: Double = 7
    
    var body: some View {
        var imageNames: [String] {
            Array(1...Int(numImages)).map { "user\($0)" }
        }
        VStack {
            Slider(value: $numImages, in: 1...7, step: 1.0)
            LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3)) {
                ForEach(imageNames, id: \.self) { name in
                    Image(name)
                        .resizable()
                        .aspectRatio(1, contentMode: .fill)
                        .border(.black)
                }
            }
            Spacer()
        }
        .padding()
    }
}


#Preview {
    ImageGridView()
}

 

GeometryReader를 통해서 설정해보도록 하겠습니다

 

import SwiftUI

struct ImageGridView: View {
    @State private var numImages: Double = 7
    
    var body: some View {
        var imageNames: [String] {
            Array(1...Int(numImages)).map { "user\($0)" }
        }
        VStack {
            Slider(value: $numImages, in: 1...7, step: 1.0)
            GeometryReader { proxy in
                let minCellWidth: CGFloat = proxy.size.width / 4
                let maxCellWidth: CGFloat = proxy.size.width / CGFloat(imageNames.count) //3,2,1장일 때
                
                //둘 중 더 큰 값을 채택하여 사용함
                let optimalCellwidth = max(minCellWidth, maxCellWidth)
                
                let numberOfColumns = Int(proxy.size.width / optimalCellwidth)
                
                //열 개수를 동적으로 생성하여 그리드 레이아웃을 구성
                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: numberOfColumns)) {
                    ForEach(imageNames, id: \.self) { name in
                        Image(name)
                            .resizable()
                            .aspectRatio(1, contentMode: .fill)
                            .border(.black)
                    }
                }
            }
            Spacer()
        }
        .padding()
    }
}


#Preview {
    ImageGridView()
}

 

이렇게 설정하게되면 3개 이하의 이미지가 보일 때

 

이미지 사이즈가 동적으로 적용됩니다

 

마지막으로 스크롤뷰에 대해서 알아보고자 합니다

import SwiftUI

struct ScrollingImageView: View {
    @State private var numImages: Double = 7
    
    var body: some View {
        var imageNames: [String] {
            Array(1...Int(numImages)).map { "user\($0)" }
        }
        GeometryReader { proxy in //부모뷰 크기 정보
            ScrollView(.horizontal) {
                HStack(spacing: 0) {
                    ForEach(imageNames, id: \.self) { name in
                        GeometryReader { gm in //각 이미지 위치와 크기 정보
                            Image(name)
                                .resizable()
                                .scaledToFill()
                                .frame(width: proxy.size.width)
                                //이미지가 이동할 때 각 이미지가 화면의 왼쪽 끝에 고정된 것처럼 보이게함
                                //고정된 위치에 계속 나타나게 하기 때문에 스크롤할 때 여러 이미지가 동시에보임
                                //뷰를 가로방향으로 이동(오른쪽 왼쪽) 뷰의 위치를 이동시킴
                                .offset(x: -gm.frame(in: .global).origin.x)
                                // 이미지가 오른쪽으로 800만큼 움직이기 때문에 대부분의 이미지가 밖으로 가가게됨 그래서 아주 일부분만 보이게됨
                                //.offset(x:800)
                                //이미지가 프레임 벗어나지 않도록 자름
                                .clipped()
                            VStack {
                                //화면 전체를 기준으로 좌표를 계산
                                Text(gm.xOrigin(space: .global))
                                    .font(.largeTitle)
                                    .foregroundStyle(.white)
                                    .padding(100)
                                //이미지 자체 크기때문에 계속 0으로 고정
                                //좌표 변화를 감지하기 어려움
                                Text(gm.xOrigin(space: .local))
                                    .font(.largeTitle)
                                    .foregroundStyle(.white)
                                    .padding(100)
                            }
                        }
                        .frame(width: proxy.size.width)
                    }
                }
            }
            .scrollIndicators(.hidden)
        }
        .ignoresSafeArea()
    }
}


#Preview {
    ScrollingImageView()
}

 

PinnedScrollableViews | Apple Developer Documentation

A set of view types that may be pinned to the bounds of a scroll view.

developer.apple.com

 

 

Grouping data with lazy stack views | Apple Developer Documentation

Split content into logical sections inside lazy stack views.

developer.apple.com

 

 

Creating performant scrollable stacks | Apple Developer Documentation

Display large numbers of repeated views efficiently with scroll views, stack views, and lazy stacks.

developer.apple.com

 

 

반응형