SwiftUI ForEach

백상휘·2023년 5월 8일
0

iOS_Programming

목록 보기
7/10

우리는 흔히 Collection 데이터를 통해 데이터를 늘어놓는 작업을 많이 한다. Collection 을 다루는 솜씨는 개발자의 필수 소양이라고 해도 과언이 아닌 것 같다.

SwiftUI 의 장점 중 하나는 뷰에 데이터를 Binding 하기 쉽다는 것이다. 데이터의 변경 뷰 렌더링을 시작하게 해 주어서 Reactive 한 뷰를 만드는 것에 특화되어 있다.

만약 다루고 있는 데이터가 Collection 이라면 대부분 ForEach 를 사용할 것이다. 단순히 언어 측면에서 제공해주는 for-loop 등이 아니라 struct 이다. 즉, 이것도 View 다. 단순히 사용법은 알 수 있지만 여러 활용법을 모르면 문제가 되는 상황이 많이 존재한다.

이 게시글은 두 가지 주제에 대해 다루고자 한다.

  1. ForEach 구조체 Overview
  2. ForEach 에서 index 사용하기

ForEach 구조체에 대하여

ForEach 는 다음과 같이 정의되어 있다.

struct ForEach<Data, ID, Content> where 
  Data : RandomAccessCollection,
  ID : Hashable
  • Data 는 실제 ForEach 에서 다룰 데이터 컬렉션이다.
  • ID 는 Data 의 element 들을 구별할 수 있는 Unique 한 값을 말한다.
  • Content 는 View 를 반환하는 클로저인데, ForEach 내부의 loop 한번당 한번 실행된다.

그리고 여느 SwiftUI 의 View 처럼 생성자가 엄청 많은데 자주 사용될 것 같은 것만 설명하려 한다.

// Creates an instance that computes views on demand over a given constant range.
// Available when Data is Range<Int>, ID is Int, and Content conforms to View.
init(Range<Int>, content: (Int) -> Content)

// -------

var body: some View {
  VStack {
  	ForEach(0..<10) {
      Text("Hello World!")
    }
  }
}

constant 하게 선언한 Range 를 이용한 ForEach 생성자이다. 현재 보여지고 있는 뷰들에 수정사항이 없다면 가장 간단한 방법 중 하나다.

// Creates an instance that uniquely identifies and creates views across updates based on the identity of the underlying data.
// Available when Data conforms to RandomAccessCollection, ID is Data.Element.ID, Content conforms to View, and Data.Element conforms to Identifiable.
init(Data, content: (Data.Element) -> Content)

// -------

var elements = Array(repeating: Element(), count: 20)

var body: some View {
  VStack {
  	ForEach(elements) { element
	  SubView(element)
    }
  }
}

Identifiable 이니 뭐니 상당히 복잡해지기 시작한다. Data 는 RandomAccessCollection 을 구현해야 하는데 RandomAccess 는 Array 의 특성이다. Data.Element 는 Identifiable 을 구현해야 한다. 내부 Element 들은 id 로 인해 전부 Unique 해야 한다는 뜻이다.

이 두 가지를 모두 충족하는 경우는 잘 없는 것 같지만 말이다.

// Creates an instance that generates Rotor content by combining, in order, individual Rotor content for each element in the data given to this ForEach.
// Available when Data conforms to RandomAccessCollection, ID conforms to Hashable, and Content conforms to AccessibilityRotorContent.
init(Data, id: KeyPath<Data.Element, ID>, content: (Data.Element) -> Content)

// -------

var elements = Array(repeating: Element(), count: 20)

var body: some View {
  VStack {
  	ForEach(elements, id: \.id) { // \.id 는 \Element.id 이다. \.self 를 사용하기도 한다.
	  SubView(element)
    }
  }
}

여기서는 id 값을 사용해야 한다. id 는 ID 타입이며, Hashable 프로토콜에 의해 생성되는 Unique 한 값이다.

처음 ForEach 를 배울 때 \.self 로 그냥 외웠던 기억이 있다. 하지만 \.self 은 객체 그 자체를 의미한다. 하지만 struct 는 identifying 할만한 것이 없기 때문에 struct 자체의 hash 값을 사용한다.

Swift 의 primitive type 들은 대부분 struct 이고 Hashable 하다. hash 값을 사용할 것임을 명시하기 위해 .self 를 사용하는 것이다.

// Creates an instance that uniquely identifies and creates views across updates based on the identity of the underlying data.
// Available when Data conforms to RandomAccessCollection and ID conforms to Hashable.
init<C, R>(Binding<C>, id: KeyPath<C.Element, ID>, editActions: EditActions<C>, content: (Binding<C.Element>) -> R)

// -------

@State var elements: [Element] = Array(repeating: Element(), count: 20)

var body: some View {
  VStack {
  	ForEach($elements, id: \.id) { element
      HStack {
        Text("Hello World!")
          .foregroundColor(element.isTapped ? .red : .blue)
        Spacer()
        Button("Change Color!!") {
          element.isTapped.toggle()
        }
      }
      .padding(.horizontal)
    }
  }
}

실무에서 가장 많이 사용되는 방법이지 않을까 싶다.

State 앞에 $ 를 붙이면 Binding 가능한 객체로 참조한다는 뜻이다. 그러므로, 전달된 객체들에 변화가 있을 경우 뷰가 다시 렌더링 된다.

위의 예제는 Button 을 탭할 경우 빨간색 혹은 파란색으로 Text 의 색을 변경해준다.

ForEach enumeration

최근 개발을 하다가 이런 경우를 만났다. 물론 실제 코드를 그대로 올리진 않았다.

@State var data: [MainData] = MainData.getDoubles()
@State var subviewData: [SubviewData] = SubviewData.getDoubles()

struct SomeView: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach($data) { item in // Target
          VStack {
            ItemCell(item: item)
            // SubItemCell(item: subviewData[index]) 가 필요한데...
          }
        }
      }
    }
  }
}

여기서 어떻게 하면 좋을까? Target 주석 부분에 이런 식으로 바꾸면 될까?

ForEach($data.enumerated()) { index, item
  ...
}

아쉽지만 다음과 같은 오류가 발생할 것이다.

Cannot convert value of type 'EnumeratedSequence<Binding<[Data]>>' to expected argument type 'Binding<C>'
EnumeratedSequence<Binding<Data>> 를 Binding<C> 로 바꿀 수 없다.

Generic parameter 'C' could not be inferred
제네릭 파라미터인 C 를 유추할 수 없다.

오류를 많이 만나본 나같이 부족한 개발자는 뭔가 처음부터 단단히 잘못되었다는 것을 느낀다. 상황을 봐서 .enumerated() 메소드로 인해 Binding<Data> 가 EnumeratedSequence 로 감싸졌다.

EnumeratedSequence 는 ForEach 공식문서에서 확인할 수 있겠지만 어떤 init 도 지원하지 않는 파라미터 타입이다.

해결책 #1 : index 도 외부 의존성을 통해 불러온다.

개인적으로 가장 좋은 방법은 데이터에 이미 고유의 index 가 같이 들어있도록 하는 것이다.

예를 들어 아주 스페셜한 백엔드 팀이 있다고 생각해보자. 대부분이 RDB 를 설치한 서버에서 어떻게 API 를 호출하는지 알려줄 것이다. 이 때 index 도 함께 저장하여 반환해달라고 하는 것이 좋다.

만약 다른 환경(Web, AOS...) 모두 동일한 순서로 보여주고 싶다면 더더욱 index 도 서버에서 관리하는 것이 좋다.

@State var data: [MainData] = MainData.getDoubles()
@State var subviewData: [SubviewData] = SubviewData.getDoubles()

struct SomeView: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach($data) { item in
          VStack {
            ItemCell(item: item)
            if item.index <= subviewData.count-1 { // IndexOutOfRange 주의!
              SubItemCell(item: subviewData[item.index])
            }
          }
        }
      }
    }
  }
}

물론 index 값에 의해 오류가 발생할 수 있지만, 서버에서 데이터를 조작하여 빠르게 문제를 해결할 수 있다.

해결책 #2 : zip 메소드 활용

구글링을 하다가 굉장하다고 생각한 방법이다. 정말 세상엔 개발 잘하는 사람들이 너무 많은 것 같다.

Tistory - SwiftUI) ForEach 에서 index 도 사용하기

해당 게시글의 게시자는 랭킹을 달아야 했기 때문에 인덱스 값을 필요로 하였다. 그래서 전달하는 데이터에 Range 객체를 zip 메소드로 합쳐 각 Element 를 Tuple 로 만들어버렸다.

zip 은 공식문서에서 다음과 같이 설명한다.

Combines elements from another publisher and deliver pairs of elements as tuples.
데이터 제공자와 다른 제공자가 가진 요소들을 각각 짝지어 튜플 형태로 합친다.

func zip<Sequence1, Sequence2>(
    _ sequence1: Sequence1,
    _ sequence2: Sequence2
) -> Zip2Sequence<Sequence1, Sequence2> where Sequence1 : Sequence, Sequence2 : Sequence

간단히 두 개의 Sequence 를 전달하여 새로운 Sequence 를 만들어준다. 주의할 것은 리턴 타입이 Zip2Sequence 이기 때문에 Array 생성자를 이용해서 배열로 만들어줘야 한다.

let arr1 = [1,2,3,4,5]
let arr2 = ["one","two","three","four","five"]

Array(zip(arr1, arr2))
// [(1,"one"), (2,"two"), (3,"three"), (4,"four"), (5,"five")]

슬슬 감이 오리라 생각이 된다. 위의 코드를 수정해보자.

@State var data: [MainData] = MainData.getDoubles()
@State var subviewData: [SubviewData] = SubviewData.getDoubles()

struct SomeView: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach(Array(zip(data.indices, $data), id: \.0) { index, item in
          VStack {
            ItemCell(item: item)
            if index <= subviewData.count-1 { // IndexOutOfRange 주의!
              SubItemCell(item: subviewData[index])
            }
          }
        }
      }
    }
  }
}

여기서 .0 은 Range<Array<MainData>.Index>.Element이다.

주의할 점은 여기서 \.self 를 사용할 수는 없다. 여기서 \.self 가 표현하는 타입은 튜플이다. 튜플만으로는 Hashable 하지 않기 때문에 채택할 수 없다. \.1 은 MainData 타입인데 Hashable 한지 확실하지 않다. 그러므로 \.0 이 가장 적절하다.

해결책 1, 2 중 알잘딱으로 가장 맞는 걸 사용하면 될 것 같다.

Reference

profile
plug-compatible programming unit

0개의 댓글