SwiftUI的ForEach无法跟踪数组元素问题
SwiftUI的ForEach无法跟踪数组元素问题

SwiftUI的ForEach无法跟踪数组元素问题

问题描述

上面的GIF内容就是本次的问题,当向右滑动时,Count和index是正常削减的,向左滑动时,Count和index不会削减。

示例中的cards数组管理着卡片的信息,其内容大致为:

cards[卡片(5)、卡片(4)、卡片(3)、卡片(2)、卡片(1)、卡片(0)]

在View视图中,通过ForEach显示:

ForEach(0..<cards.count, id: \.self) { index in
    CardView(card: cards[index]) {isRemove in
        withAnimation {
            removeCard(at: index,isRemove)
        }
    }
}

因此,卡片在视图中的显示排序为:

卡片(0)、卡片(1)、卡片(2)、卡片(3)、卡片(4)、卡片(5)

当滑动卡片时,会调用removeCard方法,如果向右滑动,则isRemove为true,对cards数组进行对应的移除操作。如果向左滑动,则最上面的卡片会移动到最底层,以便重新查看卡片。

func removeCard(at index: Int,_ isRemove :Bool) {
    guard index >= 0 else { return }
    if isRemove {
        cards.remove(at: index)
        print("Card removed")
    } else {
        let card = cards[index]
        cards.insert(card, at: 0)
        cards.remove(at: index+1)
        print("Card not removed, moved to front")
    }
}

当removeCard的isRemove变量为false时,当前的cards[index]会被保存到card中,然后插入到cards数组中,同时移除最后一个card。

可以在removeCard中添加两个for循环进行验证:

func removeCard(at index: Int,_ isRemove :Bool) {
    guard index >= 0 else { return }
    if isRemove {
        cards.remove(at: index)
        print("Card removed")
    } else {
        for i in cards {
            print(i)
        }
        let card = cards[index]
        cards.insert(card, at: 0)
        cards.remove(at: index+1)
        print("Card not removed, moved to front")
        for i in cards {
            print(i)
        }
    }
}

当向左滑动时,Card[0]从最后一位移至第一位:

Card(prompt: "5", answer: "5")
Card(prompt: "4", answer: "4")
Card(prompt: "3", answer: "3")
Card(prompt: "2", answer: "2")
Card(prompt: "1", answer: "1")
Card(prompt: "0", answer: "0")
Card not removed, moved to front
Card(prompt: "0", answer: "0")
Card(prompt: "5", answer: "5")
Card(prompt: "4", answer: "4")
Card(prompt: "3", answer: "3")
Card(prompt: "2", answer: "2")
Card(prompt: "1", answer: "1")

但实际上数组的变更并不会影响到ForEach的显示。就像最开始的Gif图片一样,当向左滑动时,仍然会删除图像,而不是将最后一个Card移至第一个位置。

排查过程

刚开始以为是数组的逻辑有问题,当添加两个for循环后,发现数组是正常的,View视图中显示的Count和index也是正常的。

然后怀疑是cards数组更新后,未能同步到View的ForEach视图。

因此,添加了DispatchQueue.main主队列:

func removeCard(at index: Int,_ isRemove :Bool) {
    guard index >= 0 else { return }
    if isRemove {
        cards.remove(at: index)
        print("Card removed")
    } else {
        DispatchQueue.main.async {
            let card = cards[index]
            cards.insert(card, at: 0)
            cards.remove(at: index+1)
            print("Card not removed, moved to front")
        }
    }
}

但是,ForEach仍然无法与数组进行同步。

问题排查

问题出现在 removeCard 方法中的实现逻辑。当左滑卡片时,将卡片重新插入到数组最前面,但这种数组操作可能导致 ForEach 遍历时的索引错误,因为 ForEach 依赖于数组的顺序和稳定的索引。

SwiftUI 的 ForEach 需要一个稳定且唯一的标识符来跟踪数组元素。如果数组元素被移动、删除或重新插入,SwiftUI可能会混淆视图的状态,导致渲染不正确。

在代码中:

let card = cards[index]
cards.insert(card, at: 0)
cards.remove(at: index + 1)

当左滑时,卡片移动到索引 0。

接着删除了原始索引 index + 1 的卡片。

这种操作导致数组元素被重新排序,但 ForEach 的 id: \.self 仍然使用数组的索引作为标识符,SwiftUI 不能正确理解元素的顺序变化,进而导致显示错误。

调试过程

1、给 Card 添加唯一标识符

Card 结构体需要添加一个唯一的标识符,比如 id:

import Foundation

struct Card: Identifiable, Codable, Equatable {
    var id = UUID()
    var prompt: String
    var answer: String

    static let example = Card(prompt: "Who played the 13th Doctor in Doctor Who?", answer: "Jodie Whittaker")
}

2、修改 ForEach 使用 Card.id 作为标识符

在 ContentView 的 ForEach 中,修改 id 参数:

ForEach(cards, id: \.id) { card in
    if let index = cards.firstIndex(where: { $0.id == card.id }) {
        CardView(card: card) { isRemove in
            withAnimation {
                removeCard(at: index, isRemove)
            }
        }
    }
}

3、修改 removeCard 方法

不再依赖于索引操作,而是直接操作 Card 对象:

func removeCard(at index: Int, _ isRemove: Bool) {
guard index >= 0 else { return }

    withAnimation {
        let card = cards.remove(at: index)
        if !isRemove {
            // 直接将卡片插入到数组的最前面
            cards.insert(card, at: 0)
        }
    }
}

重新运行,发现ForEach中的卡片可以被删除,效果也恢复正常。

但想要通过左滑将卡片移动到最底部重新展示的效果,仍然没有得到实现。

问题原因

在 SwiftUI 中,ForEach 在数据变化时依赖于元素的 id 来识别哪些视图需要更新或移动。因此,删除再重新插入元素时,如果 id 未变化,SwiftUI 可能不会触发视图的重建。这导致数据虽然变更了,但 UI 显示仍然不正确。

SwiftUI 识别更新的依据:SwiftUI 通过 id 来追踪视图。重新插入时,若 id 未变化,SwiftUI 认为这个元素没有改变,不会重新渲染。

数组顺序的更新:虽然数据顺序变了,但对于 SwiftUI 来说,原本的 id 并未发生变化,导致视图未正确反映数据变化。

解决方案

为卡片元素提供新的唯一标识符

如果元素的顺序变化频繁,重新生成 id 是一种可靠的方法。通过每次插入时重新创建 id,SwiftUI 将视为这是一个新元素,从而更新视图。

修改 Card 结构体

struct Card: Identifiable, Codable, Equatable {
    var id = UUID() // 唯一标识符
    let prompt: String
    let answer: String
}

修改 removeCard 方法

func removeCard(at index: Int, _ isRemove: Bool) {
    guard index >= 0 else { return }
    
    let card = cards.remove(at: index)
    
    if !isRemove {
        // 创建一个新的卡片,保证 id 唯一
        let newCard = Card(id: UUID(), prompt: card.prompt, answer: card.answer)
        cards.insert(newCard, at: 0)
    }
}

原理

通过重新生成 id,SwiftUI 将识别为新的数据源,从而正确触发视图的重新渲染。这避免了因 SwiftUI 内部优化而导致视图未更新的问题。

注意:还有一种方法,那就是重组数组,触发SwiftUI视图更新,但实际测试不通过,因此只把这个思路分享出来。

下面是重组数组的方法,实际并不可行:

func removeCard(at index: Int, _ isRemove: Bool) {
    guard index >= 0 else { return }
    
    var tempCards = cards   // 复制当前数组
    let card = tempCards.remove(at: index)
    
    if !isRemove {
        tempCards.insert(card, at: 0)
    }
    cards = tempCards   // 重建数组,触发 SwiftUI 视图更新
}

最终的实现效果为:

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注