반응형

SwiftUI Study – offset / position으로 레이아웃을 잡지 말아야 하는 이유

 

1. 왜 중요한가 (문제 배경)

SwiftUI에서 .offset과 .position은 매우 강력한 도구입니다.

하지만 이 둘은 “레이아웃을 잡는 도구”가 아니라 “이미 배치된 뷰를 잠깐 시각적으로 옮기는 도구”에 가깝습니다.

이들을 잘못 사용하면 다음과 같은 문제가 발생합니다.

  • 화면 크기(기기, 회전, iPad 분할)에 따라 레이아웃이 쉽게 깨짐
  • 터치 영역(hit test)이 실제 보이는 위치와 어긋남
  • 애니메이션 시 예상치 못한 움직임 발생
  • 다른 뷰와의 정렬/alignment가 꼬임

따라서 offset / position은 정말 필요한 경우에만 최소한으로 사용하고,

대부분의 레이아웃은 Stack + frame + padding + alignment로 해결하는 것이 안전합니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: offset으로 버튼을 오른쪽 아래 고정

struct WrongOffsetButtonView: View {
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()

            Button("확인") {
                print("Tap")
            }
            .padding()
            .background(Color.white)
            .cornerRadius(8)
            .offset(x: 120, y: 250)   // ❌ 특정 디바이스 기준의 하드코딩된 위치
        }
    }
}

문제점

  • iPhone 14 Pro, iPhone SE, iPad 등 해상도에 따라 위치가 모두 다르게 보임
  • 회전 시 위치가 어색해지고, 일부 디바이스에서 화면 밖으로 나갈 수도 있음

❌ 예시 2: position으로 수동 중앙 정렬

struct WrongPositionCenterView: View {
    var body: some View {
        GeometryReader { geo in
            Text("Hello")
                .position(x: geo.size.width / 2, y: geo.size.height / 2)  // ❌ 중앙 정렬을 수동 계산
        }
    }
}

문제점

  • safe area, navigation bar, tab bar 높이 변화에 취약
  • 상위 뷰 레이아웃 변경 시 계속 수정해야 함

❌ 예시 3: offset으로 카드들을 층층이 쌓는 레이아웃

struct WrongStackedCardsView: View {
    var body: some View {
        ZStack {
            ForEach(0..<3) { index in
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.blue.opacity(0.3 * Double(index + 1)))
                    .frame(width: 200, height: 120)
                    .offset(y: CGFloat(index) * 20)   // ❌ offset 기반 수직 스택
            }
        }
    }
}

문제점

  • 카드가 실제로는 같은 ZStack 위치에 존재해 hit test가 겹침
  • 카드 개수가 바뀌면 offset 계산도 수동으로 변경해야 함
  • 접근성, 스크린 리더, 포커스 이동 시 비정상적인 순서로 읽힐 수 있음

 

3. 올바른 패턴 예시

✅ 예시 1: 버튼 고정은 alignment + safeAreaInset 사용

struct CorrectFixedButtonView: View {
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()

            VStack {
                Spacer()

                Button("확인") {
                    print("Tap")
                }
                .padding()
                .background(Color.white)
                .cornerRadius(8)
                .padding(.bottom, 24)
            }
        }
    }
}

또는

struct CorrectFixedButtonSafeAreaView: View {
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()

            Text("콘텐츠 영역")
        }
        .safeAreaInset(edge: .bottom) {
            Button("확인") {
                print("Tap")
            }
            .padding()
            .frame(maxWidth: .infinity)
            .background(.ultraThinMaterial)
        }
    }
}

장점

  • 다양한 디바이스, 회전, 분할 화면에서도 자연스럽게 하단 고정
  • hit test, 접근성, 레이아웃 모두 안정적

✅ 예시 2: 중앙 정렬은 frame과 alignment로 해결

struct CorrectCenterView: View {
    var body: some View {
        Text("Hello")
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            .background(Color.yellow)
    }
}

장점

  • 부모가 자식 크기를 결정하는 SwiftUI의 기본 레이아웃 규칙과 일치
  • 안전 영역, 내비게이션 바 변동에도 자동 적응

✅ 예시 3: 카드 스택은 VStack/ZStack + padding으로 표현

struct CorrectStackedCardsView: View {
    var body: some View {
        ZStack(alignment: .top) {
            ForEach(0..<3) { index in
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.blue.opacity(0.3 * Double(index + 1)))
                    .frame(width: 200, height: 120)
                    .padding(.top, CGFloat(index) * 20)   // 레이아웃 도구를 활용
            }
        }
    }
}

또는, 더 선언적인 방법:

struct CorrectStackedCardsVStackView: View {
    var body: some View {
        VStack(spacing: -80) {
            ForEach(0..<3) { index in
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color.blue.opacity(0.3 * Double(index + 1)))
                    .frame(height: 120)
            }
        }
        .padding()
    }
}

장점

  • 카드 개수가 변해도 자연스럽게 레이아웃 재계산
  • hit test 영역이 실제 화면과 일치

 

4. 실전 적용 팁

✔ 팁 1 – offset은 “연출용”으로만 사용

  • 살짝 떠 있는 듯한 효과
  • 드래그 중 일시적인 위치 이동
  • 애니메이션 중 살짝 움직이는 효과

이런 시각적 효과 중심으로만 사용하고, 기본 정렬/배치는 Stack + frame에서 해결하는 것이 좋습니다.

✔ 팁 2 – position은 거의 마지막 수단

  • 좌표계를 직접 계산해야 하는 만큼 유지보수 비용이 큼
  • 예외적인 케이스(특수한 캔버스/차트/게임 등)를 제외하면 지양하는 편이 안전합니다.

✔ 팁 3 – “이 레이아웃을 Auto Layout으로 만든다면?”을 한 번 떠올려 보기

  • Auto Layout에서 억지로 frame.origin을 수동으로 세팅하는 코드를 떠올렸을 때
  • 불편하다면 SwiftUI에서도 offset/position 대신 제약 기반의 사고로 바꾸는 것이 맞습니다.

✔ 팁 4 – alignment, Spacer, frame(maxWidth: .infinity)를 먼저 시도

offset/position이 생각났다면, 먼저 아래 도구들을 떠올려 볼 것

  • HStack/VStack alignment
  • Spacer()
  • frame(min/maxWidth/Height)
  • safeAreaInset
  • overlay/alignmentGuide

 

5. 정리

  • .offset / .position 은 기본 레이아웃 도구가 아니라, “이미 배치된 뷰를 잠깐 옮기는 도구”에 가깝습니다.
  • 기본 레이아웃은 Stack + frame + alignment + safe area로 해결하는 것이 훨씬 안정적입니다.
  • offset/position 기반 레이아웃은 디바이스 변경, 회전, 접근성, 애니메이션에서 쉽게 깨집니다.
  • 실무에서는 offset/position을 “연출용”으로만 제한하고,
    나머지 대부분은 선언적 레이아웃 도구로 해결하는 습관을 들이는 것이 좋습니다.
반응형
Posted by 까칠코더
,