ios – @Binding property redraws child and parent view, when it should not


I have the following child view:

import SwiftUI
import Queuer

struct NCMediaScrollView: View, Equatable {
    static func == (lhs: NCMediaScrollView, rhs: NCMediaScrollView) -> Bool {
        return lhs.metadatas == rhs.metadatas
    }

    @Binding var metadatas: [tableMetadata]
    @Binding var isInSelectMode: Bool
    @Binding var selectedMetadatas: [tableMetadata]
    @Binding var columnCountStages: [Int]
    @Binding var columnCountStagesIndex: Int
    @Binding var shouldScrollToTop: Bool
    @Binding var title: String
    let proxy: ScrollViewProxy

    let queuer: Queuer

    let onCellSelected: (ScaledThumbnail, Bool) -> Void
    let onCellContextMenuItemSelected: (ScaledThumbnail, ContextMenuSelection) -> Void

    @Namespace private var topID

    var body: some View {
        let _ = Self._printChanges()

            ScrollView {
                Spacer(minLength: 70).id(topID)

                LazyVStack(alignment: .leading, spacing: 2) {
                    ForEach(metadatas.chunked(into: columnCountStages[columnCountStagesIndex]), id: \.self) { rowMetadatas in
                        NCMediaRow(metadatas: rowMetadatas, isInSelectMode: $isInSelectMode, queuer: queuer) { tappedThumbnail, isSelected in
                            onCellSelected(tappedThumbnail, isSelected)
                        } onCellContextMenuItemSelected: { thumbnail, selection in
                            onCellContextMenuItemSelected(thumbnail, selection)
                        }
                        .onAppear {
                            // BINDING UPDATES HERE
                            title = NCUtility().getTitleFromDate(rowMetadatas.first?.date as? Date ?? Date.now)
                        }
                        .background {
                            Color.clear.preference(key: TitlePreferenceKey.self, value: title)
                        }
                    }
                }
                .padding(.bottom, 40)
            }
            .onChange(of: shouldScrollToTop) { newValue in
                if newValue {
                    withAnimation {
                        proxy.scrollTo(topID)
                    }

                    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                        shouldScrollToTop = false
                    }
                }
        }
    }
}

…in where in onAppear I update the title binding.

And the following parent view:

struct NCMediaNew: View {
    let downloadThumbnailQueue = Queuer(name: "downloadThumbnailQueue", maxConcurrentOperationCount: 10, qualityOfService: .background)

    @StateObject private var vm = NCMediaViewModel()
    @EnvironmentObject private var parent: NCMediaUIKitWrapper
    @State private var title = NSLocalizedString("_media_", comment: "")
    @State private var isScrolledToTop = true
    @State private var tappedMetadata = tableMetadata()

    @State private var loadingIndicatorColor = Color.gray
    @State private var titleColor = Color.primary
    @State private var toolbarItemsColor = Color.blue
    @State private var toolbarColors = [Color.clear]

    @State private var showDeleteConfirmation = false
    @State private var showPlayFromURLAlert = false
    @State private var playFromUrlString = ""

    @State private var columnCountStages = [2, 3, 4]
    @State private var columnCountStagesIndex = 0
    @State private var columnCountChanged = false

    @State private var selectedMetadatas: [tableMetadata] = []
    @State private var isInSelectMode = false

    @State private var shouldScrollToTop = false

    var body: some View {
        ZStack(alignment: .top) {
            ScrollViewReader { proxy in
                NCMediaScrollView(metadatas: $vm.metadatas, isInSelectMode: $isInSelectMode, selectedMetadatas: $selectedMetadatas, columnCountStages: $columnCountStages, columnCountStagesIndex: $columnCountStagesIndex, shouldScrollToTop: $shouldScrollToTop, title: $title, proxy: proxy, queuer: downloadThumbnailQueue) { tappedThumbnail, isSelected in
                    if isInSelectMode, isSelected {
                        selectedMetadatas.append(tappedThumbnail.metadata)
                    } else {
                        selectedMetadatas.removeAll(where: { $0.ocId == tappedThumbnail.metadata.ocId })
                    }

                    if !isInSelectMode {
                        let selectedMetadata = tappedThumbnail.metadata
                        vm.onCellTapped(metadata: selectedMetadata)
                        NCViewer().view(viewController: parent, metadata: selectedMetadata, metadatas: vm.metadatas, imageIcon: tappedThumbnail.image)
                    }
                } onCellContextMenuItemSelected: { thumbnail, selection in
                    let selectedMetadata = thumbnail.metadata

                    switch selection {
                    case .addToFavorites:
                        vm.addToFavorites(metadata: selectedMetadata)
                    case .details:
                        NCActionCenter.shared.openShare(viewController: parent, metadata: selectedMetadata, page: .activity)
                    case .openIn:
                        vm.openIn(metadata: selectedMetadata)
                    case .saveToPhotos:
                        vm.saveToPhotos(metadata: selectedMetadata)
                    case .viewInFolder:
                        vm.viewInFolder(metadata: selectedMetadata)
                    case .modify:
                        vm.modify(metadata: selectedMetadata)
                    case .delete:
                        vm.delete(metadatas: selectedMetadata)
                    }
                }
                .equatable()
                .ignoresSafeArea(.all, edges: .horizontal)
                .scrollStatusByIntrospect(isScrolledToTop: $isScrolledToTop)
            }

            HStack {
                // THIS UPDATES VIA THE BINDING
                Text(title)
                    .font(.system(size: 20, weight: .bold))
                    .foregroundStyle(titleColor)
                    .onTapGesture {
                        vm.onRefresh()
                    }

                Spacer()

                if vm.isLoadingMetadata {
                    ProgressView()
                        .tint(loadingIndicatorColor)
                        .padding(.horizontal, 6)
                }

                Button(action: {
                    isInSelectMode.toggle()
                }, label: {
                    Text(NSLocalizedString(isInSelectMode ? "_cancel_" : "_select_", comment: "")).font(.system(size: 14))
                        .foregroundStyle(toolbarItemsColor)
                })
                .padding(.horizontal, 6)
                .padding(.vertical, 3)
                .background(.ultraThinMaterial)
                .cornerRadius(.infinity)

                if isInSelectMode, !selectedMetadatas.isEmpty {
                    ToolbarCircularButton(imageSystemName: "trash.fill", color: .red)
                        .onTapGesture {
                            showDeleteConfirmation = true
                        }
                        .confirmationDialog("", isPresented: $showDeleteConfirmation) {
                            Button(NSLocalizedString("_delete_selected_media_", comment: ""), role: .destructive) {
                                vm.deleteMetadata(metadatas: selectedMetadatas)
                                cancelSelection()
                            }
                        }
                }

                Menu {
                    if isInSelectMode, !selectedMetadatas.isEmpty {
                        Section {
                            Button {
                                vm.copyOrMoveMetadataInApp(metadatas: selectedMetadatas)
                                cancelSelection()
                            } label: {
                                Label(NSLocalizedString("_move_selected_files_", comment: ""), systemImage: "arrow.up.right.square")
                            }

                            Button {
                                vm.copyMetadata(metadatas: selectedMetadatas)
                                cancelSelection()
                            } label: {
                                Label(NSLocalizedString("_copy_file_", comment: ""), systemImage: "doc.on.doc")
                            }
                        }
                    } else {
                        Section {
                            Picker(NSLocalizedString("_media_view_options_", comment: ""), selection: $vm.filter) {
                                Label(NSLocalizedString("_media_viewimage_show_", comment: ""), systemImage: "photo.fill").tag(Filter.onlyPhotos)

                                Label(NSLocalizedString("_media_viewvideo_show_", comment: ""), systemImage: "video.fill").tag(Filter.onlyVideos)

                                Text(NSLocalizedString("_media_show_all_", comment: "")).tag(Filter.all)
                            }.pickerStyle(.menu)

                            Button {
                                selectMediaFolder()
                            } label: {
                                Label(NSLocalizedString("_select_media_folder_", comment: ""), systemImage: "folder")
                            }
                        }

                        Section {
                            Button(action: {
                                if let tabBarController = vm.appDelegate?.window?.rootViewController as? UITabBarController {
                                    NCDocumentPickerViewController(tabBarController: tabBarController, isViewerMedia: true, allowsMultipleSelection: false, viewController: parent)
                                }
                            }, label: {
                                Label(NSLocalizedString("_play_from_files_", comment: ""), systemImage: "play.circle")
                            })

                            Button(action: {
                                showPlayFromURLAlert = true
                            }, label: {
                                Label(NSLocalizedString("_play_from_url_", comment: ""), systemImage: "link")
                            })
                        }
                    }
                } label: {
                    ToolbarCircularButton(imageSystemName: "ellipsis", color: $toolbarItemsColor)
                }
            }
            .frame(maxWidth: .infinity)
            .padding([.horizontal, .top], 10)
            .padding(.bottom, 20)
            .background(LinearGradient(gradient: Gradient(colors: toolbarColors), startPoint: .top, endPoint: .bottom)
                .padding(.bottom, -50)
                .ignoresSafeArea(.all, edges: [.all])
            )

            if vm.hasNewMedia, !isScrolledToTop {
                Button {
                    shouldScrollToTop = true
                } label: {
                    Label(NSLocalizedString("_new_media_", comment: ""), systemImage: "arrow.up")
                }
                .foregroundColor(.white)
                .padding(10)
                .padding(.trailing, 3)
                .background(.blue)
                .clipShape(Capsule())
                .shadow(radius: 5)
                .offset(.init(width: 0, height: 50))
            }
        }
        .onRotate { orientation in
            if orientation.isLandscapeHardCheck {
                columnCountStages = [4, 6, 8]
            } else {
                columnCountStages = [2, 3, 4]
            }
        }
        .onChange(of: isInSelectMode) { newValue in
            if newValue == false { selectedMetadatas.removeAll() }
        }
        .onChange(of: vm.filter) { _ in
            cancelSelection()
        }
        .onChange(of: columnCountStagesIndex) { _ in
            columnCountChanged = true
        }
        .onChange(of: isScrolledToTop) { newValue in
            withAnimation(.default) {
                titleColor = newValue ? Color.primary : .white
                loadingIndicatorColor = newValue ? Color.gray : .white
                toolbarItemsColor = newValue ? .blue : .white
                toolbarColors = newValue ? [.clear] : [Color.black.opacity(0.8), Color.black.opacity(0.4), .clear]
            }

            if newValue {
                vm.hasNewMedia = false
            }
        }
        .alert("", isPresented: $showPlayFromURLAlert) {
            TextField("https://...", text: $playFromUrlString)
                .keyboardType(.URL)
                .textContentType(.URL)

            Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) {}
            Button(NSLocalizedString("_ok_", comment: "")) {
                playVideoFromUrl()
            }
        } message: {
            Text(NSLocalizedString("_valid_video_url_", comment: ""))
        }
        .gesture(
            MagnificationGesture(minimumScaleDelta: 0)
                .onChanged { scale in
                    if !columnCountChanged {
                        let newZoom = Double(columnCountStages[columnCountStagesIndex]) * 1 / scale
                        let newZoomIndex = findClosestZoomIndex(value: newZoom)
                        columnCountStagesIndex = newZoomIndex
                    }
                }
                .onEnded({ _ in
                    columnCountChanged = false
                })
        )
    }

//..............

… where I show the title in a Text view.

The problem is NCMediaScrollView updates along with NCMediaNew when it shouldn’t. title is used to display UI only in the parent, so the child should not update.

Here is an example of the view redraws every time the title updates without Equatable.

NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: @self, _metadatas, _title changed.

An example of the same, but with Equatable in NCMediaScrollView:

NCMediaScrollView: _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: _metadatas, _title changed.
NCMediaNew: _title changed.
NCMediaScrollView: _metadatas, _title changed.

An example of title not updating, with Equatable in NCMediaScrollView:

NCMediaNew: _vm changed.

What I tried:

  1. As mentioned above, I added Equtable to NCMediaScrollView to only diff the metadata array, but it still updates in this case…
  2. Moved the Title view to a separate struct in hope SwiftUI would only diff this
struct ToolbarTitle: View {
    @Binding var title: String
    @Binding var titleColor: Color

    var body: some View {
        Text(title)
            .font(.system(size: 20, weight: .bold))
            .foregroundStyle(titleColor)
            .onPreferenceChange(TitlePreferenceKey.self) { title in
                print(title)
            }
    }
}

I tried using a PreferenceKey but since this isn’t a direct parent of NCMediaScrollView the binding doesn’t work. Also tried with a regular 2-way @Binding, but all the views were still redrawing…

What else can work:

  1. Use introspect to make this work via UIKit, but this is tying me up to UIKit, so it’s a bad idea.
  2. Use callback on onAppear, and update the title only in the parent view, so no binding will be made.

Any ideas how to fix this?

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img