Environment
원저자: Chris Eidhof와 Florian Kugler. 『Thinking in SwiftUI』 iOS 17 개정판, 원문 PDF 91–104쪽의 한국어 번역입니다.
원문 91쪽은 제5장 Environment의 시작 페이지입니다.
원문 91쪽 장 표지

Environment는 SwiftUI 코드를 간결하게 만들어 주는 핵심 장치입니다. 본질적으로는 dependency injection을 수행하는 내장 방식입니다.
다음 예제를 보겠습니다. VStack에 font를 설정하면 두 text view로 전달되어 모양이 바뀝니다.
VStack {
Text("Item 1")
Text("Item 2")
}
.font(.title)View tree를 보면 .font modifier가 Font? 값을 가진 EnvironmentKeyWritingModifier로 바뀌는 것을 확인할 수 있습니다.
.font(.title) 대신 다음처럼 작성할 수도 있습니다.
VStack {
...
}
.environment(\.font, .title)결과는 정확히 같은 view tree입니다. .font modifier는 environment에 font를 기록하는 작업을 더 쉽게 찾고 편리하게 쓸 수 있도록 만든 문법일 뿐입니다.
.environment(_:value:) API의 첫 번째 parameter는 EnvironmentValues의 key path입니다. EnvironmentValues는 모든 environment 값을 저장하는 일종의 dictionary로 생각할 수 있습니다. 뒤에서 사용자 정의 값의 key path를 정의할 때 이 동작을 더 자세히 알아보겠습니다.
Environment는 view tree의 아래쪽으로 값을 전달하는 SwiftUI의 장치입니다. 이 예제에서는 EnvironmentKeyWritingModifier 아래에 있는 모든 view, 즉 VStack과 두 Text view가 environment에서 지정한 font 값을 읽을 수 있습니다. EnvironmentKeyWritingModifier 위에 있거나 view tree의 다른 가지에 있는 view는 여기에서 설정한 사용자 정의 값을 볼 수 없습니다. 이 전달 방식을 보여 주는 간단한 예제를 보겠습니다.

원문 92쪽과 도표

HStack {
VStack {
Text("Item 1")
Text("Item 2")
}
.font(.title)
Text("Hello!")
}EnvironmentKeyWritingModifier와 형제 관계인 text는 여전히 기본 font로 표시됩니다. 반면 VStack 안에 있는 두 text는 큰 title font로 렌더링됩니다.
Environment는 view tree의 root에서 leaf node까지 전달되는 key와 value의 dictionary로 생각할 수 있습니다. View tree의 어느 위치에서든 environment에 값을 기록하여 특정 key의 값을 바꿀 수 있습니다. 앞에서 .font modifier로 한 일이 바로 이것입니다. Environment에 기록하는 modifier 아래의 subtree는 변경된 environment를 받습니다.
SwiftUI의 많은 modifier는 하나의 view뿐 아니라 subtree 전체에 영향을 주기 위해 environment를 사용합니다. 예를 들어 background style, text case, font 등을 설정할 수 있습니다. 전체 목록은 EnvironmentValues 문서에 있습니다. Environment는 현재 locale이나 time zone 같은 전역 설정을 앱의 모든 view에서 사용할 수 있게 하는 데도 쓰입니다.
Environment 값 접근
@Environment property wrapper를 사용하면 environment에서 값을 읽을 수 있습니다. 특정 값에 접근하는 동시에 environment에서 그 값의 변경도 관찰합니다. 따라서 SwiftUI에서는 자동으로 갱신되는 current locale 같은 개념이 따로 필요하지 않습니다. Locale이 바뀔 때마다 environment를 통해 전달되고, 현재 locale을 읽는 모든 view가 다시 렌더링되기 때문입니다. 같은 방식으로 현재 dynamic type size 같은 값도 읽을 수 있습니다.
struct ContentView: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
HStack {
Text("Hello")
if dynamicTypeSize < .large {
Image(systemName: "hand.wave")
}
}
}
}이 예제의 코드는 원문 93–94쪽에 걸쳐 이어집니다.

원문 93쪽과 도표

위 view를 simulator의 기본 설정으로 실행하면 image가 보이지 않습니다. 하지만 dynamic type 설정을 바꾸면 content view가 자동으로 다시 렌더링되면서 그에 따라 image를 삽입하거나 제거하는 모습을 볼 수 있습니다.
Content view는 dynamic type size가 바뀔 때마다 다시 렌더링된다는 점에 주의해야 합니다. 대개는 문제가 되지 않지만, 때로는 environment의 아주 특정한 부분에만 관심이 있을 수 있습니다. 그래서 environment property wrapper에는 원하는 어떤 key path든 지정할 수 있습니다. 예를 들어 dynamic type size가 accessibility size인지에만 관심이 있다면 다음과 같이 작성할 수 있습니다.
@Environment(\.dynamicTypeSize.isAccessibilitySize) private var isAccessibilitySize이 property는 isAccessibilitySize의 값이 바뀔 때만 다시 렌더링하도록 합니다. dynamicTypeSize가 바뀔 때마다 렌더링하지는 않습니다. Dynamic type size는 거의 변하지 않으므로 이 차이가 앱의 성능을 크게 바꾸지는 않을 것입니다. 하지만 environment에 큰 값을 넣는 경우에는 더 구체적인 key path를 작성하여 불필요한 재렌더링을 피할 수 있습니다.
@State property와 마찬가지로, @Environment property를 외부에 노출할 이유는 없습니다. 그래서 저희는 보통 private으로 선언하고 lint 규칙으로 이를 확인합니다. State property와 또 다른 공통점은 view의 body 안에서만 environment를 읽을 수 있다는 것입니다. View의 initializer에서는 environment를 읽을 수 없습니다. 그 시점에는 view에 아직 identity가 없기 때문입니다. View의 initializer에서 environment property를 읽으려 하면 runtime warning이 발생합니다.
사용자 정의 Environment key
Alignment 절에서는 badge view를 만드는 방법을 살펴보았습니다. 이번 절에서는 badge의 style을 바꿀 수 있도록 확장하겠습니다. 우선 alignment는 제쳐 두고, content를 둘러싸는 badge를 그리는 view에만 집중하겠습니다. 간단한 구현은 다음과 같습니다.
원문 94쪽과 도표

struct Badge: ViewModifier {
func body(content: Content) -> some View {
content
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background {
Capsule(style: .continuous)
.fill(.blue)
}
}
}Badge는 다음처럼 사용합니다.
Text(3000, format: .number)
.modifier(Badge())지금은 badge의 style이 파란 capsule과 흰 text color로 고정되어 있습니다. Badge의 색을 바꿀 수 있도록 파란색을 tint color로 대체할 수 있습니다.
content
...
.background {
Capsule(style: .continuous)
.fill(.tint)
}이제 badge는 현재 tint color로 렌더링됩니다. 색을 지정하지 않으면 시스템 기본값을 사용합니다. 예를 들어 subtree 전체의 tint color를 빨간색으로 바꿀 수 있습니다.

원문 95쪽과 도표

VStack {
Text(3000, format: .number)
.modifier(Badge())
Text("Test")
.modifier(Badge())
}
.tint(.red)Tint color에 의존하지 않고 직접 environment key를 만들 수도 있습니다. 필수 단계는 두 가지이며, 선택적으로 한 단계를 더 진행할 수 있습니다.
- Badge color의 key로 사용할 사용자 정의
EnvironmentKey를 구현하고, 그 key에Colortype을 연결합니다. EnvironmentValuesextension에 값을 읽고 쓸 수 있는 property를 추가합니다.- 선택적으로
View에 helper method를 추가하여 subtree 전체의 badge color를 쉽게 설정할 수 있게 합니다. 이렇게 하면 사용자 정의 key와 extension을 숨기면서, 사용자가 쉽게 찾을 수 있는 API를 제공할 수 있습니다.
첫 단계로 사용자 정의 environment key를 만들겠습니다. 처음에는 조금 복잡해 보이지만 금방 익숙해질 수 있는 패턴이며, environment의 type safety를 지켜 줍니다. Environment key는 값이 아니라 type으로 정의합니다. 여기서는 빈 enum type을 key로 만들고 EnvironmentKey protocol을 채택합니다. Struct를 사용해도 됩니다.
enum BadgeColorKey: EnvironmentKey {
static var defaultValue: Color = .blue
}EnvironmentKey protocol은 static defaultValue property의 구현을 요구합니다. Type으로 Color를 사용했으므로 compiler는 이 key의 값이 언제나 Color type임을 알 수 있습니다. 기본값으로는 .blue를 제공합니다. View tree의 해당 위치에서 명시적으로 값을 설정하지 않은 채 environment의 badge color를 읽으면 이 기본값을 받습니다.
두 번째 단계로 EnvironmentValues struct에 computed property를 추가하겠습니다. badgeColor라는 이름은 environment에서 읽거나 기록할 때 사용하는 key path에 들어갑니다.

원문 96쪽과 도표

extension EnvironmentValues {
var badgeColor: Color {
get { self[BadgeColorKey.self] }
set { self[BadgeColorKey.self] = newValue }
}
}마지막으로 badge color를 설정하는 helper를 추가합니다.
extension View {
func badgeColor(_ color: Color) -> some View {
environment(\.badgeColor, color)
}
}Apple의 EnvironmentValues 문서에도 이 단계들을 위한 템플릿이 있습니다.
Badge color를 읽기 위해 Badge view modifier 안에서 @Environment property wrapper를 사용합니다.
struct Badge: ViewModifier {
@Environment(\.badgeColor) private var badgeColor
func body(content: Content) -> some View {
content
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background {
Capsule(style: .continuous)
.fill(badgeColor)
}
}
}이제 environment를 사용해 badge의 style을 바꿀 수 있습니다.
원문 97쪽과 도표

VStack {
Text(3000, format: .number)
.modifier(Badge())
Text("Test")
.modifier(Badge())
}
.badgeColor(.orange)badgeColor를 지정하지 않으면 기본값이 사용된다는 점을 기억해 두시기 바랍니다. 일반적으로는 값을 명시적으로 덮어쓰지 않는 한 environment의 기본값이 항상 사용된다고 말할 수 있습니다.
어떤 component에는 이보다 더 다양한 styling이 필요할 수 있습니다. 예를 들어 SwiftUI의 buttonStyle modifier는 ButtonStyle protocol과 함께 사용하여 button의 렌더링 방식을 완전히 바꿀 수 있게 해 줍니다. 직접 만든 component에서도 environment를 사용해 같은 기능을 구현할 수 있습니다.
사용자 정의 Component style
SwiftUI의 button이 .buttonStyle API로 style을 바꿀 수 있는 것처럼, environment를 통해 style을 바꿀 수 있는 사용자 정의 component를 만들고 싶다면 기본적인 접근은 일반 environment value를 사용할 때와 같습니다. 다만 type을 맞추기 위한 작업이 조금 더 필요합니다. 먼저 badge를 다음과 같은 API로 사용하고 싶다고 해 보겠습니다.
Text("Test")
.badge {
Text(3000, format: .number)
}Badge는 기본 badge style로 렌더링되어야 합니다. 하지만 나중에 view hierarchy에서 다음 API로 style을 덮어쓸 수 있으면 좋겠습니다.
someView
.badgeStyle(.custom)someView 안에 포함된 모든 badge는 이 새로운 사용자 정의 badge style을 받아야 합니다. 이를 구현하기 위해 다음 작업을 진행하겠습니다.
- Badge style의 interface를 정의하는
BadgeStyleprotocol을 만듭니다. - Badge style을 위한 environment key를 만듭니다.
- Badge modifier 안에서 사용자 정의 badge style을 사용합니다.
위 목록의 마지막 항목은 원문 99쪽으로 이어집니다.

원문 98쪽과 도표

Badge style의 protocol은 ViewModifier protocol과 비슷합니다. 기존 view를 badge로 감싸는 하나의 body method를 요구합니다. 이 style은 label에 badge의 외형을 적용하는 일만 담당하게 하고 싶습니다. View를 기준으로 badge를 어디에 놓을지는 badge modifier가 처리하므로 style은 신경 쓰지 않아도 됩니다. makeBody method가 AnyView를 받는다는 점에 주의하시기 바랍니다. 이 코드를 compile하려면 여기에는 구체적인 view type이 필요합니다.
protocol BadgeStyle {
associatedtype Body: View
@ViewBuilder func makeBody(_ label: AnyView) -> Body
}이제 기본 badge style을 만들 수 있습니다.
struct DefaultBadgeStyle: BadgeStyle {
var color: Color = .red
func makeBody(_ label: AnyView) -> some View {
label
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background {
Capsule(style: .continuous)
.fill(color)
}
}
}하지만 environment key를 만들 때 key의 value type으로 DefaultBadgeStyle을 그대로 사용할 수는 없습니다. 그렇게 하면 다른 type의 사용자 정의 badge style을 사용할 수 없게 됩니다. 대신 existential을 사용해 구체적인 type을 숨기겠습니다. 본질적으로는 구체적인 type을 상자에 감싸는 방식입니다. AnyView가 구체적인 view를 감싸는 것과 비슷합니다. Environment key와 이에 대응하는 property는 다음과 같습니다.
enum BadgeStyleKey: EnvironmentKey {
static var defaultValue: any BadgeStyle = DefaultBadgeStyle()
}원문 99쪽과 도표

extension EnvironmentValues {
var badgeStyle: any BadgeStyle {
get { self[BadgeStyleKey.self] }
set { self[BadgeStyleKey.self] = newValue }
}
}Badge style을 사용하기 위해 사용자 정의 view modifier를 만들겠습니다. 이 modifier는 environment에서 style을 읽고, label을 변형한 다음, 지정된 alignment에 맞춰 배치합니다. Label이 ideal size를 갖도록 만드는 일도 맡습니다. Badge의 크기가 badge를 붙인 view의 크기에 좌우되지 않게 하기 위해서입니다. Alignment guide를 사용하는 layout 코드는 Layout 장의 alignment 절에서 다룬 것과 비슷합니다.
struct OverlayBadge<BadgeLabel: View>: ViewModifier {
var alignment: Alignment = .topTrailing
var label: BadgeLabel
@Environment(\.badgeStyle) private var badgeStyle
func body(content: Content) -> some View {
content
.overlay(alignment: alignment) {
AnyView(badgeStyle.makeBody(AnyView(label)))
.fixedSize()
.alignmentGuide(alignment.horizontal) { $0[HorizontalAlignment.center] }
.alignmentGuide(alignment.vertical) { $0[VerticalAlignment.center] }
}
}
}OverlayBadge modifier를 public으로 만들 필요는 없습니다. 대신 View protocol에 짧은 helper method를 작성하여 이 절의 시작에서 보여 드린 문법을 사용할 수 있게 합니다.
extension View {
func badge<V: View>(alignment: Alignment = .topTrailing,
@ViewBuilder _ content: () -> V) -> some View {
modifier(OverlayBadge(alignment: alignment, label: content()))
}
}이제 badge modifier를 다음과 같이 사용할 수 있으며, environment에 있는 현재 badge style이 자동으로 적용됩니다.
원문 100쪽과 도표

Text("Hello")
.badge { Text(100, format: .number) }Environment의 style이 실제로 사용되는지 보여 주기 위해, 광택이 있는 사용자 정의 badge style을 만들어 보겠습니다.
struct FancyBadgeStyle: BadgeStyle {
var background: some View {
ZStack {
ContainerRelativeShape()
.fill(Color.red)
.overlay {
ContainerRelativeShape()
.fill(LinearGradient(colors: [.white, .clear],
startPoint: .top, endPoint: .center))
}
ContainerRelativeShape()
.strokeBorder(Color.white, lineWidth: 2)
.shadow(radius: 2)
}
}
func makeBody(_ label: AnyView) -> some View {
label
.foregroundColor(.white)
.font(.caption)
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(background)
.containerShape(Capsule(style: .continuous))
}
}Environment에 badge style을 더 쉽게 설정할 수 있도록 다시 View extension을 추가합니다.
extension View {
func badgeStyle(_ style: any BadgeStyle) -> some View {
environment(\.badgeStyle, style)
}
}
원문 101쪽과 도표

이제 hierarchy의 어느 위치에서든 .badgeStyle(FancyBadgeStyle())을 지정하면 이 badge style을 사용할 수 있습니다. 해당 subtree 전체가 이 style로 렌더링됩니다. 문법을 더 간결하게 만들기 위해 BadgeStyle에 다음 extension을 추가할 수 있습니다.
extension BadgeStyle where Self == FancyBadgeStyle {
static var fancy: FancyBadgeStyle {
FancyBadgeStyle()
}
}이제 .badgeStyle(.fancy)라고 쓸 수 있습니다.
HStack {
Text("Inbox")
Text("Spam")
.badge {
Text(3000, format: .number)
}
}
.badgeStyle(.fancy)BadgeStyle 정의에 AnyView를 사용해야 했다는 점은 아쉽습니다. 하지만 associated Body type은 정해져 있어야 하며 Label에 따라 달라질 수 없으므로, 이를 generic parameter로 만들 수는 없습니다. Kasper Lahti의 글에서 보여 주듯 별도의 Label struct를 만들어 이 사실을 숨길 수는 있습니다. 다만 이 책을 쓰는 시점에는 AnyView 없이 이 기능을 작성할 수 없습니다. 같은 글에서는 사용자 정의 badge style 안에서 @Environment property 등에 접근하는 방법도 보여 줍니다.

원문 102쪽과 도표

Environment object
Environment는 값뿐 아니라 object를 전달하는 데도 사용할 수 있습니다. 이 동작 방식은 iOS 17과 macOS 14에서 바뀌었으므로, 지원할 플랫폼을 구분해야 합니다.
iOS 17부터는 environment object에 새로운 @Observable macro를 사용하고, property는 이 장에서 계속 사용한 @Environment property wrapper로 선언합니다. 다른 점은 EnvironmentKey protocol을 채택하는 별도의 key type을 선언하지 않고, environment object의 type 자체를 key로 사용할 수 있다는 것입니다.
다음은 environment object를 사용하는 간단한 예제입니다.
@Observable final class UserModel {
...
}
struct Nested: View {
@Environment var userModel: UserModel?
var body: some View {
Text(userModel?.name ?? "default name")
...
}
}Hierarchy의 더 위쪽에 있는 view가 environment에 user model을 설정했다면, view tree의 모든 단계를 거쳐 object를 직접 전달하지 않아도 여기에서 접근할 수 있습니다. Environment에 object가 설정되지 않았다면 userModel property는 nil이 됩니다. Object를 설정하는 방법은 간단합니다.
struct ContentView: View {
var body: some View {
Nested()
.padding()
.environment(UserModel())
}
}Environment object의 property를 non-optional type으로 선언할 수도 있습니다. 이때는 해당 object가 environment에 존재한다고 전제해야 합니다. 그렇지 않으면 environment property에 접근할 때 앱이 crash합니다. Object의 type을 environment key로 사용하는 것은 편리하지만 꼭 그래야 하는 것은 아닙니다. 이 장 앞부분에서 한 것처럼 environment key를 직접 정의하고, 명시적인 key를 사용해 view tree 아래로 object를 전달할 수도 있습니다.
마지막 설명은 원문 104쪽으로 이어집니다.
원문 103쪽과 도표

iOS 17 이전 플랫폼을 지원하려면 두 가지 다른 API를 사용해야 합니다. Environment에서 object를 읽을 때는 @EnvironmentObject property wrapper를 사용하고, environment에 object를 설정할 때는 environmentObject modifier를 사용합니다. 이 API들도 object의 type을 key로 사용합니다. 다만 object는 state object나 observed object와 마찬가지로 ObservableObject protocol을 채택해야 합니다.
@EnvironmentObject를 사용할 때는 property를 optional type으로 선언할 수 없습니다. 지정한 type의 object가 environment에 설정되지 않은 상태에서 @EnvironmentObject property에 접근하면 코드가 crash합니다. 적어도 environment object를 설정하는 코드를 하나의 helper method로 모아 두면 도움이 됩니다.
extension View {
func injectDependencies() -> some View {
environmentObject(UserModel())
.environmentObject(Database())
}
}이 helper는 앱의 root view뿐 아니라 preview에 모든 dependency를 주입할 때도 사용할 수 있습니다. 그러면서도 특정 위치에서 dependency를 덮어쓸 수 있습니다.
struct Nested_Previews: PreviewProvider {
static var previews: some View {
Nested()
.environmentObject(UserModel.mock())
.injectDependencies()
}
}Environment object는 subclassing과도 잘 동작합니다. UserModel.mock()이 UserModel의 subclass를 반환하더라도 nested view에서 올바르게 읽을 수 있습니다.
원문 104쪽과 도표
