[SwiftUI] UI 상태 관리 (Managing user interface state)

2025. 4. 15. 18:25·iOS/SwiftUI
728x90
반응형

안녕하세요, iOS 개발하는 루피입니다!

 

오늘은 SwiftUI의 UI 상태 관리에 대해 공식 문서를 바탕으로 정리해 보겠습니다.

 

바로 시작합니다.


Overview

SwiftUI에서 상태 관리의 핵심은 각 뷰가 필요한 데이터를 캡슐화하여 독립적이고 재사용 가능한 컴포넌트로 만드는 것입니다.


캡슐화란?

여기서 캡슐화란 뷰가 데이터(@State)와 동작(UI 렌더링)을 내부에 정리하고, 외부에는 간단한 인터페이스(@Binding, 읽기 전용 속성)만 노출하는 것을 의미합니다. 이렇게 하면 뷰는 복잡한 내부 로직을 숨기고, 다른 화면이나 앱에서 쉽게 재사용할 수 있습니다.

하지만 여기서 한 가지 오해가 생길 수 있습니다.

"아 그러면.... 모든 데이터들을 딱 뷰에 맞게 분산시키는 게 포인트구나???"

아니요. 데이터를 뷰마다 분산시키는 게 아니라, 데이터를 필요로 하는 뷰들의 가장 가까운 공통 조상 뷰에 @State나 @ObservableObject로 저장하여 SSOT를 유지하는 것이 중요합니다.


SSOT(단일 진실 원천)란?

데이터의 "진실"이 한 곳에만 존재하도록 보장해서, 뷰 간 데이터 불일치나 복잡한 동기화 문제를 피하는 방법입니다.

코드로 한번 살펴보겠습니다.

import SwiftUI

// 데이터 모델: 에피소드 정보
struct Episode {
    let title: String
    let showTitle: String
}

// 상위 뷰: 재생 상태를 관리하고 UI를 구성
struct PlayerView: View {
    let episode: Episode
    @State private var isPlaying: Bool = false // 캡슐화된 상태

    var body: some View {
        VStack(spacing: 16) {
            Text(episode.title)
                .font(.headline)
            Text(episode.showTitle)
                .font(.subheadline)
            PlayButton(isPlaying: $isPlaying) // 바인딩으로 상태 공유
        }
        .padding()
        .background(isPlaying ? Color.green.opacity(0.2) : Color.gray.opacity(0.2)) // 상태에 따라 배경색 변화
        .animation(.easeInOut(duration: 0.5), value: isPlaying) // 부드러운 전환
    }
}

재생 상태(isPlaying)를 관리하는 PlayerView가 있다면, @State private var isPlaying을 뷰 내부에 캡슐화합니다.

// 하위 뷰: 재생/일시정지 버튼
struct PlayButton: View {
    @Binding var isPlaying: Bool // 부모의 상태를 참조

    var body: some View {
        Button(action: {
            withAnimation(.easeInOut(duration: 0.5)) {
                isPlaying.toggle() // 상태 변경
            }
        }) {
            Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
                .resizable()
                .frame(width: 50, height: 50)
                .scaleEffect(isPlaying ? 1.0 : 1.2) // 재생 상태에 따라 크기 변화
                .foregroundStyle(.blue)
        }
        .animation(.spring(), value: isPlaying) // 버튼 애니메이션
    }
}

하위 뷰(PlayButton)에는 @Binding으로 공유합니다. 데이터의 진실은 PlayerView에 있지만, PlayButton은 그 데이터를 참조하며 독립적으로 동작할 수 있죠.

 

SwiftUI는 이 흐름을 강력하게 지원합니다. 데이터는 읽기 전용 속성(let episode: Episode)으로 전달하거나, @Binding을 통해 상태와 양방향 연결할 수 있습니다. 상태가 바뀌면 SwiftUI는 자동으로 영향을 받는 뷰를 갱신하며, withAnimation을 사용하면 이 전환이 부드러운 애니메이션으로 표현됩니다. 예를 들어, isPlaying이 바뀌면 버튼 크기나 배경색이 자연스럽게 전환되죠.

 

이 접근법은 캡슐화와 SSOT를 조화시켜 뷰를 재사용 가능하게 만듭니다. 각 뷰는 필요한 데이터만 관리하고, 데이터의 원천은 명확히 한 곳에 유지되며, SwiftUI의 선언적 특성은 이 모든 변화를 매끄럽게 연결해 줍니다.


상태 관리의 핵심 원칙

한 뷰에 저장된 상태가 다른 뷰와는 바인딩으로 양방향 연결되고, 또 다른 뷰와는 속성으로 단방향 연결되어 공유됩니다.

상태 속성은 뷰의 생명주기와 동일하기 때문에 영구 저장소로 사용해서는 안 됩니다. 대신, 버튼의 하이라이트 상태, 필터 설정, 선택된 리스트 항목처럼 UI에만 영향을 미치는 일시적인 상태를 관리하는 데 사용할 수 있습니다.

 

또한, 앱의 데이터 모델을 수정하기 전에 프로토타이핑 단계에서 상태 속성을 활용하면 편리할 수 있습니다.


1. 변경 가능한 데이터를 상태로 관리하기

뷰에서 수정할 수 있는 데이터를 저장해야 한다면, @State 속성 래퍼를 사용해 변수를 선언하세요. 예를 들어, 팟캐스트 플레이어 뷰에서 팟캐스트가 재생 중인지 아닌지를 추적하려면 isPlaying이라는 Bool 값을 만들 수 있습니다.

struct PlayerView: View {
    @State private var isPlaying: Bool = false

    var body: some View {
        // ...
    }
}

@State로 표시하면 SwiftUI가 이 데이터의 저장소(메모리)와 뷰 갱신 같은 사항들을 관리해 줍니다.

뷰는 변수 이름을 통해 데이터를 읽거나 수정할 수 있고, 이 데이터는 @State의 wrappedValue에 저장돼요.


WrappedValue란?

@State가 데이터를 감싸고 있는 구조체의 속성 이름이에요. 예를 들어, @State var isPlaying: Bool은 State<Bool> 구조체로 관리되고, 그 안의 wrappedValue가 true/false를 저장해요. 하지만 코드에서는 wrappedValue를 직접 쓸 일 거의 없고, 그냥 isPlaying으로 접근하면 됩니다.

 

데이터가 바뀌면 SwiftUI는 영향을 받는 뷰 부분을 자동으로 갱신합니다. 예를 들어, PlayerView에 버튼을 추가해서 탭할 때마다 isPlaying 값을 바꾸고, 그 값에 따라 다른 이미지를 보여줄 수 있습니다:

Button(action: {
    self.isPlaying.toggle() // 재생/일시정지 토글
}) {
    Image(systemName: isPlaying ? "pause.circle" : "play.circle") // 재생 중이면 일시정지 아이콘, 아니면 재생 아이콘
}

상태 변수는 private으로 선언해서 범위를 제한하세요. 이렇게 하면 변수가 해당 뷰 계층 내에서만 사용되도록 캡슐화되어, 다른 뷰에서 실수로 건드릴 일이 없어요.


2. 변경되지 않는 데이터를 Swift 속성으로 저장하기

뷰에서 수정하지 않는 데이터를 제공하려면, 일반 Swift 속성을 선언하세요.

예를 들어, 팟캐스트 플레이어 뷰를 확장해서 에피소드 제목과 쇼 이름을 담은 입력 구조체를 추가할 수 있습니다:

struct PlayerView: View {
    let episode: Episode // 재생 목록에 있는 에피소드
    @State private var isPlaying: Bool = false

    var body: some View {
        VStack {
            // 에피소드 정보 표시
            Text(episode.title) // 에피소드 제목
            Text(episode.showTitle) // 쇼 이름

            Button(action: {
                self.isPlaying.toggle() // 재생/일시정지 전환
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle") // 재생 여부에 따라 아이콘 변경
            }
        }
    }
}

PlayerView에서 episode 속성은 상수로 고정돼 있지만, 부모 뷰에서는 이 값이 바뀔 수 있습니다. 사용자가 부모 뷰에서 다른 에피소드를 선택하면, SwiftUI는 상태 변화를 감지하고 새로운 입력값으로 PlayerView를 다시 만들어줍니다.

예시 코드로 같이 보겠습니다.

struct Episode {
    let title: String
    let showTitle: String
}

struct ParentView: View {
    @State private var selectedEpisode = Episode(title: "Episode 1", showTitle: "루피의 팟캐스트")

    var body: some View {
        PlayerView(episode: selectedEpisode)
    }
}

struct PlayerView: View {
    let episode: Episode // 부모로부터 받은 데이터
    @State private var isPlaying: Bool = false

    var body: some View {
        VStack {
            Text(episode.title)
            Text(episode.showTitle)
            Button(action: {
                isPlaying.toggle()
            }) {
                Image(systemName: isPlaying ? "pause.circle" : "play.circle")
            }
        }
    }
}

이처럼 ParentView에서 PlayerView에 어떤 에피소드 인스턴스를 전달하냐에 따라 부모로부터 받는 데이터가 달라지게 됩니다.


3. 상태를 자식 뷰와 공유하기 위해 바인딩 사용하기

뷰가 자식 뷰와 상태를 함께 제어해야 한다면, 자식 뷰에서 @Binding 속성 래퍼를 사용해 속성을 선언하세요.

@Binding은 기존 데이터 저장소를 참조해서, 데이터의 SSOT을 유지합니다. 예를 들어, 팟캐스트 플레이어 뷰의 버튼을 PlayButton이라는 자식 뷰로 분리한다면, isPlaying 상태를 @Binding으로 연결할 수 있습니다

struct PlayButton: View {
    @Binding var isPlaying: Bool // 부모의 상태를 참조

    var body: some View {
        Button(action: {
            isPlaying.toggle() // 상태 변경
        }) {
            Image(systemName: isPlaying ? "pause.circle" : "play.circle") // 상태에 따라 아이콘 변경
        }
    }
}

위처럼 @Binding은 @State처럼 변수 이름으로 값을 읽거나 수정할 수 있습니다. 하지만, @State와 달리 @Binding은 자체 저장소를 가지지 않아요. 대신, 다른 곳(보통 부모 뷰)에 저장된 @State를 참조하며, 그 데이터와 양방향 연결을 만듭니다.

PlayButton을 만들 때, 부모 뷰의 @State 변수 앞에 달러 기호($)를 붙여 바인딩을 전달합니다.

struct PlayerView: View {
    var episode: Episode // 수정 불가 데이터
    @State private var isPlaying: Bool = false // 상태

    var body: some View {
        VStack {
            Text(episode.title)
            Text(episode.showTitle)
            PlayButton(isPlaying: $isPlaying) // 바인딩 전달
        }
    }
}

$ 기호는 @State의 투영값, 즉 데이터 저장소에 접근하는 바인딩을 가져옵니다.

 

심지어 @Binding에서도 $를 써서 또 다른 바인딩을 만들 수 있어서, 뷰 계층을 여러 단계 거쳐도 바인딩을 전달할 수 있습니다. 또한, @State 변수 안의 특정 값에 바인딩을 만들 수도 있습니다. 예를 들어, 부모 뷰에서 episode을 @State로 선언하고, 그 안에 isFavorite라는 Bool 값이 있다면, $episode.isFavorite로 그 값에 바인딩을 연결할 수 있습니다:

struct Podcaster: View {
    @State private var episode = Episode(title: "어떤 에피소드",
                                         showTitle: "멋진 쇼",
                                         isFavorite: false) // 상태로 관리

    var body: some View {
        VStack {
            Toggle("즐겨찾기", isOn: $episode.isFavorite) // isFavorite에 바인딩
            PlayerView(episode: episode)
        }
    }
}


4. 상태 전환 애니메이션 적용하기

뷰의 상태(@State)가 바뀌면 SwiftUI는 영향을 받는 뷰를 즉시 갱신합니다. 하지만 이 변화를 부드럽게 만들고 싶다면, 상태 변화를 withAnimation(::) 함수로 감싸서 SwiftUI에게 애니메이션을 적용하라고 알려줄 수 있습니다.

 

예를 들어, isPlaying 상태를 바꾸는 코드를 애니메이션으로 처리할 수 있습니다:

withAnimation(.easeInOut(duration: 1)) {
    self.isPlaying.toggle() // 1초 동안 부드럽게 전환
}

isPlaying을 애니메이션 함수 안에서 바꾸면, 이 값에 의존하는 모든 UI 요소가 애니메이션으로 전환됩니다:

Image(systemName: isPlaying ? "pause.circle" : "play.circle")
    .scaleEffect(isPlaying ? 1 : 1.5) // 재생 중이면 1배, 멈췄으면 1.5배

SwiftUI는 지정한 애니메이션 곡선과 시간을 사용해 크기 효과를 1에서 1.5로, 또는 그 반대로 부드럽게 전환합니다.

만약 곡선이나 시간을 지정하지 않으면 기본값을 사용해요. 하지만 이미지 자체는 같은 isPlaying 값에 의존하더라도 애니메이션이 적용되지 않아요. SwiftUI는 두 문자열 사이를 부드럽게 전환할 방법이 없기 때문입니다.

 

애니메이션은 @State나 @Binding 모두에 적용할 수 있습니다. 어떤 경우든, 데이터가 바뀌면서 영향을 받는 뷰는 애니메이션이 적용돼요. 예를 들어, PlayerView에 배경색을 추가하면, 이 배경색도 애니메이션으로 전환됩니다:

VStack {
    Text(episode.title)
    Text(episode.showTitle)
    PlayButton(isPlaying: $isPlaying)
}
.background(isPlaying ? Color.green : Color.red) // 배경색이 부드럽게 바뀜

만약 상태 변화로 영향을 받는 모든 뷰가 아니라 특정 뷰에만 애니메이션을 적용하고 싶다면, animation(_:value:) 모디파이어를 사용해 보면 좋을 거 같습니다.


참고

Managing user interface state | Apple Developer Documentation

 

Managing user interface state | Apple Developer Documentation

Encapsulate view-specific data within your app’s view hierarchy to make your views reusable.

developer.apple.com

 

오늘도 화이팅 입니다!

728x90
반응형

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

[SwiftUI] StateObject  (0) 2025.04.21
[SwiftUI] Bindable  (1) 2025.04.21
[SwiftUI] 상태 관리 - PropertyWrapper (2)  (0) 2025.04.17
[SwiftUI] 상태 관리 - PropertyWrapper (1)  (0) 2025.04.17
[SwiftUI] Model Data  (0) 2025.04.15
'iOS/SwiftUI' 카테고리의 다른 글
  • [SwiftUI] Bindable
  • [SwiftUI] 상태 관리 - PropertyWrapper (2)
  • [SwiftUI] 상태 관리 - PropertyWrapper (1)
  • [SwiftUI] Model Data
kimsangjunzzang
kimsangjunzzang
루피 님의 블로그 입니다.
  • kimsangjunzzang
    루피 님의 블로그
    kimsangjunzzang
  • 전체
    오늘
    어제
    • 분류 전체보기 (93) N
      • iOS (57) N
        • Swift (28) N
        • UIKit (9)
        • SwiftUI (8)
        • RxSwift (12)
      • FE (8)
        • 모던 자바스크립트 (3)
        • HTML (5)
      • Operating System (1)
      • 트러블 슈팅 (4)
      • 바로 안 나오면 모르는거다 (4)
      • Algorithm (16)
      • 회고록 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    ViewController
    CS
    AppleDeveloperAcademy
    swift
    ios
    면접
    백준
    rxswift
    HTML
    arc
    C++
    알고리즘
    디자인 패턴
    state
    uikit
    SwiftUI
    boj
    web
    Algorithm
    Concurrency
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
kimsangjunzzang
[SwiftUI] UI 상태 관리 (Managing user interface state)
상단으로

티스토리툴바