본문 바로가기

iOS+/SwiftUI

EnvironmentValues

개요

이 글은 SwiftUI의 EnvironmentValues를 중심으로 다룬다

 

프로젝트를 리팩토링하며 SwiftUI의 기능을 적극 활용하고자 고민하던 중, Reusable View 영상을 통해 재사용 가능한 뷰 설계를 하는 법에 대해 배울 수 있었다

해당 영상에서는 SwiftUI에서 뷰에 대해 리팩토링하는 여러가지 기법들을 설명하는데, 유독 EnvironmentValues가 눈에 들어왔다

왜냐하면 굳이 왜 사용하는지 잘 이해가 안갔었기 때문이다

 

그렇기 때문에 좀 더 사용의 필요성을 확 느낄 수 있는 예시들이 글의 대부분을 차지한다

그 글에서는 예시를 크게 두 가지로 분류했는데, 하나는 단순 값으로 이용이고, 나머지 하나는 상태를 통한 이용이다

 

전체적인 글은 다음 순서로 진행된다:

  • Environment와 EnvironmentValues
  • 예시를 통한 이해
  • @Entry (WWDC24)

 

Environment와 EnvironmentValues

@Environment는 뷰의 환경에서 값을 읽어오는 데 사용되는 프로퍼티 래퍼다

 

이를 뷰에서 사용하려면 .environment 수정자를 통해 전달받아야 한다

이를 통해 상위 뷰에서 설정된 데이터를 하위 뷰에서 손쉽게 접근할 수 있다

Environment는 다음 상황에서 유용하다:

  • 코드 간결화: Prop Drilling을 없애고, 중복 코드를 최소화.
  • 유연성 제공: 기본값을 설정하고, 필요 시 개별 뷰에서 오버라이드 가능.
  • 확장성 강화: extension View로 수정자를 추가해 재사용성과 가독성 향상.
  • 일관성 유지: 앱 전역에서 설정을 통일적으로 관리.

 

EnvironmentValues는 환경 값을 저장하는 구조체로, @Observable 클래스뿐만 아니라 일반 구조체나 클래스를 확장해 커스텀 환경 값을 정의할 수 있다

 

지금까지 나는 .environment 수정자로 @Observable 객체만 전달 후 사용하기만 했었지만, EnvironmentValues를 활용하면 더 체계적인 환경 값 관리가 가능하단 것을 깨달게 되었다

EnvironmentValues 커스텀 키 사용

다음과 같은 단계를 거치면 EnvironmentValues를 사용할 수 있다

 

  1. EnvironmnetKey 정의
  2. EnvironmentValues 확장
  3. View 확장(선택)

 

// 1. EnvironmentKey 정의
private struct ThemeKey: EnvironmentKey {
    static let defaultValue: Theme = .light
}

// 2. EnvironmentValues 확장
extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

// 3. 편의를 위한 View 확장
extension View {
    func theme(_ theme: Theme) -> some View {
        environment(\\.theme, theme)
    }
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            ChildView()
                .theme(.dark)
        }
    }
}

struct ChildView: View {
    @Environment(\\.theme) var currentTheme
    
    var body: some View {
        Text("현재 테마: \\(currentTheme == .dark ? "다크" : "라이트")")
            .foregroundColor(currentTheme == .dark ? .white : .black)
    }
}

 

extension View에서 environment(_:_:)는 View에 수정자인 .environment로 환경 값을 키에 등록하는 것과 같다

 

// 3. 편의를 위한 View 확장
extension View {
    func theme(_ theme: Theme) -> some View {
        environment(\\.theme, theme)
    }
}

ChildView()
		.environment(\\.theme, theme)

 

예시를 통한 이해

WWDC23에서 @Observable이 발표되고, 나는 주로 @Observable 객체를 .environment로 뷰에 전달해 상태 변화를 관리해왔다

그래서 EnvironmentValues도 상태 관리에만 유용할 거라 생각했지만, 그게 전부가 아니었다

 

상태가 아닌 단순 값도 EnvironmentValues로 유용하게 활용될 수 있는데, 아래 예시를 통해 이를 확인할 수 있다

뷰 렌더링 컨텍스트 관리

예를 들어, 동일한 ContentCard 뷰를 서로 다른 스타일로 렌더링하고 싶다면 어떻게 해야 할까?

 

 

가장 간단한 방법은 ViewMode를 정의하고, viewMode 매개변수를 뷰에 추가해 조건부로 처리하는 것이다:

 

enum ViewMode {
    case normal, preview, compact
}

struct ContentCard: View {
        let viewMode: ViewMode
        let title: String
        let description: String
        
        var body: some View {
            VStack(alignment: .leading) {
                Text(title)
                    .font(viewMode == .compact ? .subheadline : .headline)
                
                if viewMode != .compact {
                    Text(description)
                        .font(.body)
                        .lineLimit(viewMode == .preview ? 2 : nil)
                }
                
                if viewMode == .normal {
                    Button("자세히 보기") { }
                        .padding(.top, 8)
                }
            }
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
        }
}

ContentCard(viewMode: .normal, title: "일반 모드 카드", description: "...")

 

하지만 EnvironmentValues를 사용하면 더 효율적인 방법이 가능하다

이를 일반 매개변수 전달 방식과 비교하면 다음과 같은 장점이 있다:

 

계층적 오버라이드

뷰 계층의 각 수준에서 값을 유연하게 오버라이드할 수 있다

예를 들어, 앱 전체는 .normal로 설정하되 특정 섹션에서만 .compact로 변경 가능하다

ContentCard(title: "제목", description: "설명")
    .environment(\\.viewMode, .compact) // 특정 뷰에만 .compact 적용

 

중첩된 뷰에 자동 전파

하위 뷰가 viewMode에 접근해야 할 때, 매개변수를 일일이 전달할 필요가 없다(EnvironmentValues를 사용 -> 코드 간소화)

View 확장을 수행했다면, 기본값으로 .compact가 전달된다

VStack {
	// 직접 전달
	ContentCard(viewMode: .normal, title: "제목", description: "설명")
	
	// EvironmentValues 사용
	ContentCard(title: "제목", description: "설명")
}
.environment(\\.viewMode, .compact)

 

코드 유지보수 용이성

새로운 렌더링 옵션(예: 다크 모드, 접근성 크기)이 추가될 때, 모든 뷰의 생성자를 수정할 필요 없이 EnvironmentValues에만 정의하면 된다

 

예시 코드

1. 환경 키, 값 정의 및 뷰 확장

// EnvironmentKey 정의
private struct ViewModeKey: EnvironmentKey {
    static let defaultValue: ViewMode = .normal
}

// EnvirnmentValues 정의
extension EnvironmentValues {
    var viewMode: ViewMode {
        get { self[ViewModeKey.self] }
        set { self[ViewModeKey.self] = newValue }
    }
}

// View 확장
extension View {
    func cardRender(_ viewMode: ViewMode) -> some View {
        environment(\\.viewMode, viewMode)
    }
}

 

2. 뷰에서 환경 값 사용

기존에 인자로 전달받던 값을 환경을 사용하도록 수정한다

struct ContentCard: View {
    @Environment(\\.viewMode) private var viewMode
    let title: String
    let description: String
    ...

 

사용 예시

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            ContentCard(
                title: "일반 모드 카드",
                description: "이것은 일반 모드에서 표시되는 카드입니다. 모든 콘텐츠가 표시됩니다."
            )
            
            ContentCard(
                title: "미리보기 모드 카드",
                description: "미리보기 모드에서는 설명이 2줄로 제한됩니다. 버튼은 표시되지 않습니다."
            )
            .cardRender(.preview)
            
            ContentCard(
                title: "컴팩트 모드 카드",
                description: "컴팩트 모드에서는 제목만 표시됩니다."
            )
            .cardRender(.compact)
        }
        .padding()
    }
}

상태와 연동

SwiftUI의 List는 편집 모드(editMode)를 지원하며, 이는 EnvironmentValues에서 editMode 키로 제공되는 환경 값이다

 

EditMode가 활성화되면 List는 삭제(delete)와 이동(move) 기능을 기본적으로 제공한다

 

이를 EnvironmentValues로 관리하면 다음과 같은 장점이 있다:

  • 코드 간결화: editMode를 각 뷰에 Prop으로 전달하지 않고 환경 값으로 관리.
  • 유연성 제공: List와 하위 뷰에서 편집 모드를 일관되게 제어할 수 있으며, 필요 시 개별 뷰에서 추가 로직(예: 삭제 버튼 커스터마이징)을 쉽게 구현 가능.

예시 코드

@State로 editMode를 정의하고 .environment(\.editMode, $editMode)를 통해 List와 하위 뷰(ListRow)에 주입한다

 

Binding을 사용해 상태를 연동하므로, 상위 뷰의 버튼 클릭으로 editMode가 .active와 .inactive 간 전환될 때 List의 삭제/이동 기능과 하위 뷰의 스타일이 즉각 반영된다

이처럼 Prop 전달 없이도 상태를 환경 값으로 관리하는 장점을 얻을 수 있다

 

struct ContentView: View {
    @State private var editMode: EditMode = .inactive
    
    var body: some View {
        VStack {
            Button(action: {
                editMode = (editMode == .inactive) ? .active : .inactive
            }) {
                Text(editMode == .inactive ? "Edit" : "Done")
            }
            .padding()
            
            List {
                ForEach(0..<10) { index in
                    ListRow(index: index)
                }
                .onDelete { indices in }
            }
            // List와 하위 뷰에 환경 값 주입
            .environment(\\.editMode, editMode)
        }
    }
}

 

@Entry(WWDC24)

WWDC24에서 발표한 내용에 따르면 아래와 같이 기존에 환경 키, 값을 등록하던 방식을 매크로로 간단하게 처리할 수 있도록 @Entry가 생겨났다

 

enum ViewMode {
    case normal, preview, compact
}

// EnvironmentKey 정의
private struct ViewModeKey: EnvironmentKey {
    static let defaultValue: ViewMode = .normal
}

// EnvirnmentValues 정의
extension EnvironmentValues {
    var viewMode: ViewMode {
        get { self[ViewModeKey.self] }
        set { self[ViewModeKey.self] = newValue }
    }
}

 

위 같은 과정을 아래처럼 간소화시킬 수 있다

 

// Entry 매크로로 간소화
extension EnvironmentValues {
		@Entry var viewMode: ViewMode = .normal
}

 

'iOS+ > SwiftUI' 카테고리의 다른 글

@Namespace와 .matchedGeometryEffect  (0) 2024.07.21