SwiftUI ForEach 내부의 Sheet에 대하여

Sheet와 ForEach

Sheet는 뷰를 Modal하게 띄우기 위한 modifier이고, ForEach는 뷰를 반복적으로 렌더링하기 위한 SwiftUI의 View입니다. 그래서 기본적으로 다음과 같이 다른 뷰를 Modal하게 띄워주는 List 형태의 화면을 구성할수 있습니다.

import SwiftUI

struct Todo: Identifiable {
  let id = UUID()
  var text: String
  var due: String
}

struct SwiftUIView: View {
  private var todos = [
    Todo(text: "Task 1", due: "Mon"),
    Todo(text: "Task 2", due: "Wed"),
    Todo(text: "Task 3", due: "Fri"),
    Todo(text: "Task 4", due: "Sat"),
    Todo(text: "Task 5", due: "Sun")
  ]

  @State private var sheetTodo: Todo?

  var body: some View {
    List {
      ForEach(todos) { todo in
        HStack {
          Text(todo.text)
          Spacer()
          Button("Detail") {
            sheetTodo = todo
          }
        }
      }
    }
    .sheet(item: $sheetTodo) { todo in
      VStack {
        Text("My Sheet View")
        Text("Task: \(todo.text)")
        Text("Due: \(todo.due)")
      }
    }
  }
}

이 코드를 간단하게 알아보겠습니다.

struct Todo: Identifiable {
  let id = UUID()
  var text: String
  var due: String
}

이 부분은 Model이라고도 할 수 있는 부분으로 Todo의 구조체를 만들어놓은 것입니다.

private var todos = [
  Todo(text: "Task 1", due: "Mon"),
  Todo(text: "Task 2", due: "Wed"),
  Todo(text: "Task 3", due: "Fri"),
  Todo(text: "Task 4", due: "Sat"),
  Todo(text: "Task 5", due: "Sun")
]

이 부분은 todos 배열을 선언하고 초기화 한 부분입니다.

var body: some View {
  List {
    ForEach(todos) { todo in
      HStack {
        Text(todo.text)
        Spacer()
        Button("Detail") {
          sheetTodo = todo
        }
      }
    }
  }
  .sheet(item: $sheetTodo) { todo in
    VStack {
      Text("My Sheet View")
      Text("Task: \(todo.text)")
      Text("Due: \(todo.due)")
    }
  }
}

이 부분은 실제 View의 UI를 구성하는 부분입니다. 이 코드를 실행보면 다음과 같이 UI가 구성되고, Detail을 누르면 그에 해당하는 View가 Modal하게 띄워집니다.

swiftui-sheet-inside-foreach

실수하는 부분

ForEach 혹은 List와 같은 루프와 Sheet를 같이 사용할 때 많이 범하는 실수가 있습니다. 루프안에 Sheet를 사용하는 것인데요. 다음 코드가 그 실수를 대변하는 하나의 예시입니다.

import SwiftUI

struct Todo: Identifiable {
  let id = UUID()
  var text: String
  var due: String
}

struct SwiftUIView: View {
  private var todos = [
    Todo(text: "Task 1", due: "Mon"),
    Todo(text: "Task 2", due: "Wed"),
    Todo(text: "Task 3", due: "Fri"),
    Todo(text: "Task 4", due: "Sat"),
    Todo(text: "Task 5", due: "Sun")
  ]

  @State private var showTodo = false
  @State private var sheetTodo: Todo?

  var body: some View {
    List {
      ForEach(todos) { todo in
        HStack {
          Text(todo.text)
          Spacer()
          Button("Detail") {
            showTodo.toggle()
          }
          .sheet(isPresented: $showTodo) {
            VStack {
              Text("My Sheet View")
              Text("Task: \(todo.text)")
              Text("Due: \(todo.due)")
            }
          }
        }
      }
    }
  }
}

이렇게 코드를 작성하고 실행해보면, 어떤 Detail을 눌러도 마지막 Detail에 대한 View가 Modal 하게 띄워집니다. 그 이유는 Sheet가 ForEach 루프안에 있고, 그에 따라 각 Detail 버튼마다 Sheet이 생성되어 showTodo 변수가 toggle 될 때 모든 버튼의 Sheet이 활성화 되어서 그런 것입니다.

그렇기 때문에 이를 주의하고, Sheet는 루프 밖에서 만드는 것이 대체로 좋습니다.

데이터 Binding

때로는 루프안에서 사용되는 데이터와 Sheet로 넘겨주는 데이터를 Binding 시켜줄 필요가 있습니다. 예를들어, Sheet로 띄우는 View에 TextField를 사용하는 경우가 있습니다. 그럴때는 다음과 같이 코드를 작성하면됩니다.

import SwiftUI

struct Todo: Identifiable {
  let id = UUID()
  var text: String
  var due: String
}

struct SwiftUIView: View {
  @State private var todos = [
    Todo(text: "Task 1", due: "Mon"),
    Todo(text: "Task 2", due: "Wed"),
    Todo(text: "Task 3", due: "Fri"),
    Todo(text: "Task 4", due: "Sat"),
    Todo(text: "Task 5", due: "Sun")
  ]

  @State private var showTodo = false
  @State private var sheetTodo: Todo?

  var body: some View {
    List(todos) { todo in
      HStack {
        Text(todo.text)
        Spacer()
        Button("Detail") {
          sheetTodo = todo
        }
      }
    }
    .sheet(item: $sheetTodo) { todo in
      VStack {
        Text("My Sheet View")
        TextField("Task: ", text: $todos.first { $0.id == todo.id }!.text)
      }
    }
  }
}

다른 부분은 대부분 똑같고, 아래 부분만 살짝 다릅니다.

.sheet(item: $sheetTodo) { todo in
  VStack {
    Text("My Sheet View")
    TextField("Task: ", text: $todos.first { $0.id == todo.id }!.text)
  }
}

우선, Sheet의 파라미터가 isPresented가 아닌 item으로 item안에 들어가는 값이 변경될때 sheet가 발동됩니다. 그리고 TextField와 같이 데이터와 Binding 된 값이 필요한 경우, todos 내에서 id 값을 비교해 해당 element를 받아와 사용하면됩니다.