How to reload / update cells in TableView and CollectionView using Diffable DataSource

Doyeong Kim·2023년 9월 8일
0

Swift

목록 보기
9/9

안녕하세요! 오늘은 Diffable DataSource를 활용하여 value type 아이템들의 데이터 변경을 어떻게 하는지에 대해 포스팅을 써보려 합니다.

위의 동영상처럼 상세화면에서 하트 인터렉션이 발생했을 때, 리스트 화면에서도 하트 정보가 업데이트 되어야하는 상황으로 예시를 들어볼게요!

Reference 타입과 Value 타입에 따라 다른 reload 방법

무슨 말이냐 하면, 참조 타입의 경우엔 스냅샷의 reloadItems(_:) 를 활용하여 지정된 항목을 다시 로드할 수 있지만, 값 유형의 경우엔 reloadItems(_:) 는 작동하지 않으므로 스냅샷 내에서 업데이트 된 항목을 수동으로 교체해야 한다는 것입니다.

바로 코드로 볼게요!

Reference Type

  1. Diffable 데이터 소스 생성: ItemIdentifier 타입에 객체를 넣어줍니다.
var dataSource: UICollectionViewDiffableDataSource<CommunityCategoryType, StoryNew>!
  1. 셀 리로딩
// 1. IndexPath로 해당 item 찾기
guard let communityStories = dataSource.itemIdentifier(for: indexPath) else { return }
    
// 2. 기존 객체를 새로운 객체로 업데이트
communityStories = newStory

// 3. 수정을 위한 새로운 snapshot 생성
var newSnapshot = dataSource.snapshot()
    
// 4. reload
newSnapshot.reloadItems([communityStories])
    
// 5. apply changes
dataSource.apply(newSnapshot)

Value Type

처음에 언급했듯이, 값 타입의 아이템은 reloadItems(_:) 에서 작동하지 않습니다.

저도 프로젝트 진행하면서 위의 방법으로는 업데이트가 안되는 거에요.. 왜일까 생각해보니 대부분의 모델이 값 타입인 Struct로 구현이 되어있는데 참조 타입 방식으로 업데이트 하려하니 안되는 거였어요!

간단히 말하면, StoryNew가 value type이라면, communityStories는 StoryNew의 새 인스턴스가 될거고, 스냅샷 내에서 communityStories로 객체를 가리키지 않을거죠? 따라서 newSnapshot에서 communityStories를 다시 로드하려고 하면 “Invalid item identifier specified for reload“라는 이유로 NSInternalInconsistencyException 예외가 발생할거에요.

그래서 찾은 해경방법은! 스냅샷 내에서 기존의 객체를 새 객체로 교체하면 가능합니다!

  1. Diffable 데이터 소스 생성: ItemIdentifier 타입에 String 을 넣어줍니다.
var dataSource: UICollectionViewDiffableDataSource<CommunityCategoryType, StoryNew>!
  1. 셀 리로딩
// 1. IndexPath로 해당 item 찾기
guard let communityStories = dataSource.itemIdentifier(for: indexPath) else { return }
    
// 2. 새로운 스토리 카피를 생성 & 업데이트 
var updatedStories = communityStories
updateStories = newStory 
// updateStories.heartButton.isSelected = true 이런식으로 바꿔도 상관없음.

// 3. 수정을 위한 새로운 snapshot 생성
var newSnapshot = dataSource.snapshot()
    
// 4. 업데이트 된 객체로 replace
newSnapshot.insertItems([updateStories], beforeItem: communityStories)
newSnapshot.deleteItems([communityStories])
  
newSnapshot.reloadItems([communityStories])
    
// 5. apply changes
dataSource.apply(newSnapshot)

결과물:


근데 보면 음.. insert / delete로 업데이트를 해주다 보니 뒤로 가면 스크롤 위치가 계속 바뀌더라구요!

이러한 불편함을 없애기 위해 찾아낸 방법도 공유해볼게요!


<해결 방법>

모델의 identifier를 Diffable Data Source의 Item Identifier로 활용하기!

  1. 첫 번째로 해야 할 일은 우리의 StoryNew 구조체에 고유한 식별자를 추가하는 것입니다. 이 식별자를 저는 identifier로 지정하고 UUID()를 이 식별자로 사용할게요!
struct StoryNew: Codable, Hashable {
    var identifier = UUID()
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(identifier)
    }
    
    static func == (lhs: StoryNew, rhs: StoryNew) -> Bool {
        return lhs.identifier == rhs.identifier
    }
}

  1. 그리고 datasource의 item identifier 타입에 StoryNew가 아닌 UUID 으로 바꿔줍니다.
var dataSource: UICollectionViewDiffableDataSource<Section, UUID>!

  1. UUID와 해당 StoryNew 객체를 갖고있는 dictionary도 하나 생성해줄게요. 이렇게 함으로써 stories 배열을 루프로 반복하지 않고도 identifier를 사용해 StoryNew 객체를 쉽게 가져올 수 있습니다.
var storyDic = [UUID: StoryNew]()

  1. 그럼 이제 dictionary에 값을 넣어줘야겠죠. [StoryNew][(UUID, StoryNew)]로 변환한 후, Dictionary(uniqueKeysWithValues:) 를 사용해서 [UUID : StoryNew]로 변환해볼게요.
// convert `[StoryNew]` -> `[(UUID, StoryNew)]`
let tupleArray = stories.map { ($0.identifier, $0) }

// convert `[(UUID, StoryNew)]` -> `[UUID : StoryNew]`
self.storyDic = Dictionary(uniqueKeysWithValues: tupleArray)

  1. storyDictionary가 준비되면 이제 셀 register로 넘어가볼게요. CellRegistration Item 타입에 UUID로 변경해야합니다. 기존과는 달리 모델 객체가 아닌 UUID를 제공하기 때문에 cell registration handler도 살짝 변경이 필요하겠죠!
let cellRegistration = UICollectionView.CellRegistration<CommunityCollectionViewCell, UUID> { (cell, indexPath, uuid) in
	// `uuid`를 사용하여 Story 가져오기
	if let story = self.viewModel.storyDic[uuid] {
    	cell.setData(story: story)
	}
}

Cell Registration 핸들러 내에서 주목해야 할 점은 storyDic을 사용하여 해당 스토리 객체를 가져오는 방법입니다.


  1. 마지막은 data source snapshot을 어떻게 채워 업데이트할지 입니다. 이제 스냅샷에 모델 객체를 추가하는 대신 모델 객체 identifier를 스냅샷에 추가해야 합니다.
// <Section, UUID>
var dataSource: UICollectionViewDiffableDataSource<CommunityCategoryType, UUID>!

// 스냅샷 items로 모든 `identifier` 추가
snapshot.appendItems(stories.map { $0.identifier }, toSection: categoryType)

dataSource.apply(snapshot, animatingDifferences: true)

이렇게 하면 모델 객체의 식별자를 data source item 식별자로 사용하도록 성공적으로 변환 성공!

자 그러면 이제 제일 중요한 셀 업데이트는 어떻게 할까?

기존과의 차이점은 data source가 모델 객체 대신 identifier를 제공한다는 것입니다. 따라서 우리는 storyDic을 사용하여 story를 가져와 그에 따라 업데이트를 해야합니다.

func updateItem(selectedId: UUID) {
	var updated = storyDic[selectedId]!
	
    // story 하트 업데이트하고 그에 따라 `storyDic` 업데이트
    if let isLiked {
		updated.isMyEmpathized = isLiked
		updated.nEmpathies = likeCount ?? (updated.nEmpathies ?? 0) + (isLiked ? 1 : -1)
        storyDic[selectedId] = updated
	}
    
	// 수정을 위해 데이터 소스 스냅샷의 새 복사본 생성
	var newSnapshot = dataSource.snapshot()

	if #available(iOS 15, *) {
    	// iOS 15
        // `newSnapshot`에서 업데이트해야 하는 항목의 데이터 지정하기 (`StoryNew.identifier`를 사용)
		newSnapshot.reconfigureItems([selectedId])
	} else {
		// iOS 14
		newSnapshot.reloadItems([selectedId])
	}

	// data source에 `newSnapshot` 적용하여 변경 사항이 컬렉션 뷰에 반영되도록 함
	dataSource.apply(newSnapshot)
}

끝이에요!

이렇게 data source item identifier에 모델 객체 대신 고유 식별자 (identifier)를 저장하니 reference / value 타입에서 전부 다 작동되는 것을 확인했습니다!

결과물:

스크롤 위치가 변경되지 않고 그대로 유지되고 셀 업데이트도 정상적으로 작동 되는게 보이죠~?


여담으로 iOS 15 이상부터 사용 가능한 reconfigureItems(_:) 가 왜 reloadItems(_:) 보다 훨씬 성능이 좋을까요??

그 이유는 reconfigureItems" 는 변경된 항목만 업데이트하고 새로 그리는 대신, 필요한 변경 사항에만 중점을 둡니다. 반면 reloadItems는 셀을 완전히 다시 그리기 때문에 더 많은 작업을 수행해야 합니다.

결국 reconfigureItems 는 더 작은 범위의 변경 사항을 처리하고 셀 렌더링을 최적화하는 데 중점을 두어 더 빠른 결과를 제공하는 거네요! (iOS 15 이상부터는 reconfigreItems를 사용하자~~😃)


아래 WWDC 영상에서 Diffable DataSource 관련한 더 많은 개선점들이 나와있으니 보시는 걸 추천드릴게요!

https://developer.apple.com/videos/play/wwdc2021/10252/
https://developer.apple.com/documentation/uikit/uiimage/building_high-performance_lists_and_collection_views (Sample Code)

profile
신비로운 iOS 세계로 당신을 초대합니다.

0개의 댓글