[SwiftUI] NavigationLink pop back unexpectedly

Kihyun Lee·2022년 8월 10일
1

SwiftUI

목록 보기
5/5

Problem


바인딩된 객체를 수정할 때 의도치 않게 내비게이션 링크가 되돌아 가지는 문제를 겪었다.

기대했던 결과 화면은 이렇다.

.isDetailLink(), EmptyView(), .navigationViewStyle() 등등 많은 방법을 시도해 봤지만 단 하나도 해결책이 되지 못했다.

결국 혼자 코드를 보면서 어렵게 문제의 원인을 찾을 수 있었다. 결론 먼저 얘기하자면 ForEach(id:) 부분에 있었다.

Code

이해를 돕기 위해 간단한 테스트 코드를 만들어 보았다.

data

struct Temp: Hashable {
    var name: String
    var content: String
}

class Temps: ObservableObject {
    @Published var items: [Temp] = [
        Temp(name: "first", content: "this is the first content")
    ]
    
    func change() {
        items[0].content = "changed!"
    }
}

커스텀 구조체 Temp 여러개를 저장하는 class 인 Temps 를 선언했다. Temp 는 ForEach 로 View 를 그릴 때 id 계산에 이용되기 위해 Hashable 프로토콜을 채택했다. Temps 는 View들 간에 공유되기 위해 ObservableObject 프로토콜과 @Published 프로퍼티 래퍼를 사용했다. 그리고 첫 번째 Temp 를 약간 수정하는 change 함수가 있다.

view

struct FirstView: View {
    @StateObject var temps = Temps()
    
    var body: some View {
        NavigationView {
            VStack {
                ForEach(temps.items, id: \.self) { item in
                    NavigationLink(destination: SecondView(temps: temps)) {
                        Text(item.name)
                    }
                }
            }
        }
    }
    
}

struct SecondView: View {
    @ObservedObject var temps: Temps
    
    var body: some View {
        VStack {
            Text(temps.items[0].content)
            
            Button("change") {
                temps.change()
            }
        }
    }
}

자 이제 FirstView 에서 NavigationLink 로 temps 객체를 SecondView로 넘겨준다. SecondView 에서 바인딩된 temps 객체를 받아서 아까 선언했던 change() 버튼을 실행시키면 첫 번째 Temp 의 content가 변경된다. 여기서 문제가 발생한다.

누르지도 않은 Back 버튼이 눌린 것 처럼 FirstView 로 돌아가게 되는 것이다. Temp 가 수정이 잘 되긴 했지만 되돌아가는 것은 우리가 원하는 액션이 아니다.

Why?

FirstView 에서 NavigationLink 를 만들 때 ForEach(temps.items, id: \.self) 를 썼다. 이것의 의미는 SwiftUI가 temp.items 를 가지고 View 를 만들 때 각각을 구별하기 위한 id 로 .self 를 쓰겠다는 말이다. 여기서는 정확히 item 들의 타입이 Temp 이기 때문에 Temp 의 모든 프로퍼티를 가지고 id 값을 계산한다는 말이된다. 따라서 Temp 의 name, content 프로퍼티로 id 가 계산된다.

그러므로 처음에 그린 NavigationLink 의 id 가 change() 가 실행되면서 바뀐 것이다! SecondView 로 이어지는 Link 의 id 가 바뀌었으니 SwiftUI 가 NavigationLink 를 다시 그리게 되면서 현재 우리가 보고 있던 SecondView 가 마치 삭제된 것 처럼 뒤로 가게 되는 것이다.

Solution

  1. \.self 대신 \.name 과 같이 수정되지 않을 만한 프로퍼티를 id 로 사용
  2. indices 의 \.self 를 id 로 사용
  3. Temp 구조체가 Hashable 대신 Identifiable 프로토콜 채택
  1. 간단하지만 id 를 name 으로 계산하겠다는 의미이므로 name 값이 같은 다른 Temp 가 존재하거나 name 이 확실히 변경되지 않는다는 보장이 없다면 위험하다.

  2. ForEach(temps.items, id: \.self) { item in
    // 변경 ->
    ForEach(temps.items.indices, id: \.self) { index in

    이렇게 인덱스를 아이디로 쓰면 클로저로 index 가 넘어가기 때문에 item 에 접근할 때 매번 temps.items[index] 를 써줘야 해서 코드가 길어지고 읽기 불편해 진다는 단점이 있다.

  3. struct Temp: Identifiable {
        var id: Int
        var name: String
        var content: String
    }

    Temp 의 id 를 Hash 로 계산하지 않고 직접 식별할 수 있는 id 를 준다는 것이다. 이렇게 되면 ForEach(temps.items) 처럼 id 인자를 생략할 수 있다. 하지만 새로운 Temp 객체를 만들 때 Temp(id: 4, name: "", content: "") 처럼 항상 id 값을 부여해 주어야 한다는 점 + 나중에 items.firstIndex(of:) 같은 메소드를 쓸 때 Equatable 프로토콜 또한 채택해 주어야 한다는 점 등의 단점이 있다.

    또는 id 에 Int 가 아닌 UUID() 를 직접 넣는 방법도 있다. Temp 객체가 새로 생성될 때마다 UUID 도 자동으로 생성되는 방식이다. id 가 무작위로 생성되기 때문에 겹칠 수도 있지만 경우의 수가 너무 많기에 확률이 0 에 가깝다.

Done

  • 바인딩 객체를 사용하기 위해 @ObservedObject 를 썼지만 @EnvironmentObject 에서도 문제는 똑같이 발생한다.
  • @Published 는 변화가 감지된 곳만 refresh 되도록 Swift 가 최적화한다. 따라서 Temp 객체 내용이 처음으로 "changed!" 로 바뀔땐 뒤로 가버리지만 두 세번째 버튼에선 같은 값을 계속 대입해서 변화가 감지되지 않아 뷰가 refresh 되지 않는다.
profile
실패도 배우는 게 있으면 성공이다.

0개의 댓글