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:
- As mentioned above, I added Equtable to
NCMediaScrollView
to only diff themetadata
array, but it still updates in this case… - 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:
- Use
introspect
to make this work via UIKit, but this is tying me up to UIKit, so it’s a bad idea. - Use callback on
onAppear
, and update thetitle
only in the parent view, so no binding will be made.
Any ideas how to fix this?