From 6114cc0eaa6cf82ec22e61117ca1ded0f3b0aa1f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 07:26:15 +0200 Subject: [PATCH 01/22] feat: prepare media date navigation by month Replace the media date label with a button and index the first media item for each year and month. Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+Command.swift | 12 ++++--- iOSClient/Media/NCMedia.storyboard | 27 +++++++-------- iOSClient/Media/NCMedia.swift | 7 ++-- iOSClient/Media/NCMediaDataSource.swift | 46 +++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index a1c3fc07f6..472167b0fd 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -37,22 +37,23 @@ extension NCMedia { let sortedAttributes = layoutAttributes.sorted { $0.frame.minY < $1.frame.minY || ($0.frame.minY == $1.frame.minY && $0.frame.minX < $1.frame.minX) } if let firstAttribute = sortedAttributes.first, let metadata = dataSource.getCompactMetadata(indexPath: firstAttribute.indexPath) { - titleDate?.text = utility.getTitleFromDate(metadata.date) + buttonDate.setTitle(utility.getTitleFromDate(metadata.date), for: .normal) return } } - titleDate?.text = "" + buttonDate.setTitle("", for: .normal) } func setElements() { - let highTextTitle = titleDate.frame.height + let highTextTitle = buttonDate.frame.height let isOver = self.collectionView.contentOffset.y + highTextTitle <= -view.safeAreaInsets.top && self.collectionView.contentOffset.y != -view.safeAreaInsets.top if isOver || dataSource.compactMetadatas.isEmpty { UIView.animate(withDuration: 0.3) { [self] in gradientView.isHidden = true - titleDate?.textColor = NCBrandColor.shared.textColor + buttonDate.setTitleColor(NCBrandColor.shared.textColor, for: .normal) + buttonDate.tintColor = NCBrandColor.shared.textColor activityIndicator.color = NCBrandColor.shared.textColor if #unavailable(iOS 26.0) { @@ -62,7 +63,8 @@ extension NCMedia { } else { UIView.animate(withDuration: 0.3) { [self] in gradientView.isHidden = false - titleDate?.textColor = .white + buttonDate.setTitleColor(.white, for: .normal) + buttonDate.tintColor = .white activityIndicator.color = .white if #unavailable(iOS 26.0) { diff --git a/iOSClient/Media/NCMedia.storyboard b/iOSClient/Media/NCMedia.storyboard index fcdb7e83f1..4f13484491 100644 --- a/iOSClient/Media/NCMedia.storyboard +++ b/iOSClient/Media/NCMedia.storyboard @@ -1,8 +1,8 @@ - + - + @@ -38,37 +38,36 @@ - + - + + - + - - + + - diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 4f2dfc9721..a355af2cbd 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -9,7 +9,7 @@ import RealmSwift class NCMedia: UIViewController { @IBOutlet weak var collectionView: UICollectionView! - @IBOutlet weak var titleDate: UILabel! + @IBOutlet weak var buttonDate: UIButton! @IBOutlet weak var activityIndicator: UIActivityIndicatorView! @IBOutlet weak var gradientView: UIView! @@ -131,8 +131,9 @@ class NCMedia: UIViewController { gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] gradientView.layer.insertSublayer(gradientLayer, at: 0) - titleDate.text = "" - titleDate?.textColor = .white + buttonDate.setTitle("", for: .normal) + buttonDate.setTitleColor(.white, for: .normal) + buttonDate.tintColor = .white activityIndicator.color = .white pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 831ade75df..10dcb6054c 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -531,6 +531,7 @@ public class NCMediaDataSource: NSObject { private let utilityFileSystem = NCUtilityFileSystem() private let global = NCGlobal.shared private(set) var compactMetadatas: [NCCompactMetadata] = [] + private(set) var firstIndexByYearMonth: [NCYearMonth: Int] = [:] override init() { super.init() } @@ -540,6 +541,10 @@ public class NCMediaDataSource: NSObject { self.compactMetadatas = metadatas.map { getCompactMetadataFromMetadata($0) } + + self.firstIndexByYearMonth = makeFirstIndexByYearMonth( + from: compactMetadatas + ) } private func getCompactMetadataFromMetadata(_ metadata: tableMetadata) -> NCCompactMetadata { @@ -595,4 +600,45 @@ public class NCMediaDataSource: NSObject { ocId.contains(item.ocId) } } + + private func makeFirstIndexByYearMonth(from metadatas: [NCCompactMetadata]) -> [NCYearMonth: Int] { + var result: [NCYearMonth: Int] = [:] + + for (index, metadata) in metadatas.enumerated() { + guard let yearMonth = NCYearMonth(date: metadata.date) else { + continue + } + + if result[yearMonth] == nil { + result[yearMonth] = index + } + } + + return result + } + + public func firstIndex(year: Int, month: Int) -> Int? { + firstIndexByYearMonth[NCYearMonth(year: year, month: month)] + } +} + +public struct NCYearMonth: Hashable { + public let year: Int + public let month: Int + + public init(year: Int, month: Int) { + self.year = year + self.month = month + } + + init?(date: Date, calendar: Calendar = .current) { + let components = calendar.dateComponents([.year, .month], from: date) + + guard let year = components.year, + let month = components.month else { + return nil + } + + self.init(year: year, month: month) + } } From eccd0911f77e404dfb27268d828d76a501953bcb Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 07:44:57 +0200 Subject: [PATCH 02/22] fix: improve media date button styling and gradient transition Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+Command.swift | 49 +++++++++++++++------------ iOSClient/Media/NCMedia.swift | 18 ++++++++-- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 472167b0fd..684f1bc63d 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -47,31 +47,36 @@ extension NCMedia { func setElements() { let highTextTitle = buttonDate.frame.height - let isOver = self.collectionView.contentOffset.y + highTextTitle <= -view.safeAreaInsets.top && self.collectionView.contentOffset.y != -view.safeAreaInsets.top + let isOver = + collectionView.contentOffset.y + highTextTitle <= -view.safeAreaInsets.top && + collectionView.contentOffset.y != -view.safeAreaInsets.top + + let shouldHideGradient = isOver || dataSource.compactMetadatas.isEmpty + let foregroundColor: UIColor = shouldHideGradient ? NCBrandColor.shared.textColor : .white + var configuration = buttonDate.configuration + configuration?.baseForegroundColor = foregroundColor + buttonDate.configuration = configuration + activityIndicator.color = foregroundColor + + if #unavailable(iOS 26.0) { + (navigationController as? NCMediaNavigationController)? + .updateRightBarButtonsTint(to: foregroundColor) + } - if isOver || dataSource.compactMetadatas.isEmpty { - UIView.animate(withDuration: 0.3) { [self] in - gradientView.isHidden = true - buttonDate.setTitleColor(NCBrandColor.shared.textColor, for: .normal) - buttonDate.tintColor = NCBrandColor.shared.textColor - activityIndicator.color = NCBrandColor.shared.textColor + if !shouldHideGradient { + gradientView.isHidden = false + } - if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: NCBrandColor.shared.textColor) - } - } - } else { - UIView.animate(withDuration: 0.3) { [self] in - gradientView.isHidden = false - buttonDate.setTitleColor(.white, for: .normal) - buttonDate.tintColor = .white - activityIndicator.color = .white - - if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: .white) - } + UIView.animate( + withDuration: 0.3, + animations: { + self.gradientView.alpha = shouldHideGradient ? 0 : 1 + }, + completion: { _ in + self.gradientView.isHidden = shouldHideGradient } - } + ) + setTitleDate() } } diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index a355af2cbd..61024c7159 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -131,9 +131,21 @@ class NCMedia: UIViewController { gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] gradientView.layer.insertSublayer(gradientLayer, at: 0) - buttonDate.setTitle("", for: .normal) - buttonDate.setTitleColor(.white, for: .normal) - buttonDate.tintColor = .white + var configuration = buttonDate.configuration ?? UIButton.Configuration.plain() + configuration.title = "" + let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + configuration.image = UIImage(systemName: "chevron.down", withConfiguration: symbolConfiguration) + configuration.imagePlacement = .trailing + configuration.imagePadding = 8 + configuration.baseForegroundColor = .white + configuration.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = UIFont.systemFont(ofSize: 20, weight: .semibold) + return outgoing + } + buttonDate.configuration = configuration + activityIndicator.color = .white pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) From 09d729420a4f63d99cf9d282e1b44c2e33c16f11 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 08:22:09 +0200 Subject: [PATCH 03/22] feat: add date picker navigation to media timeline Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 4 + iOSClient/Media/NCMedia+Command.swift | 137 +++++++++++- iOSClient/Media/NCMedia.storyboard | 3 + iOSClient/Media/NCMedia.swift | 4 + iOSClient/Media/NCMediaDataSource.swift | 41 +++- .../NCMediaDatePickerViewController.swift | 203 ++++++++++++++++++ 6 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 iOSClient/Media/NCMediaDatePickerViewController.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 60fdfe7095..21d13b9b3e 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -758,6 +758,7 @@ F7BB7E4727A18C56009B9F29 /* Parchment in Frameworks */ = {isa = PBXBuildFile; productRef = F7BB7E4627A18C56009B9F29 /* Parchment */; }; F7BC287E26663F6C004D46C5 /* NCViewCertificateDetails.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */; }; F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */; }; + F7BCE48E301091EA00876F4D /* NCMediaDatePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */; }; F7BD0A002C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */; }; F7BD0A022C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD0A012C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift */; }; F7BD0A042C4689E9003A4A6D /* NCMedia+MediaLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD0A032C4689E9003A4A6D /* NCMedia+MediaLayout.swift */; }; @@ -1758,6 +1759,7 @@ F7BB04851FD58ACB00BBFD2A /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCViewCertificateDetails.storyboard; sourceTree = ""; }; F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewCertificateDetails.swift; sourceTree = ""; }; + F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaDatePickerViewController.swift; sourceTree = ""; }; F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+CollectionViewDataSource.swift"; sourceTree = ""; }; F7BD0A012C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+CollectionViewDelegate.swift"; sourceTree = ""; }; F7BD0A032C4689E9003A4A6D /* NCMedia+MediaLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+MediaLayout.swift"; sourceTree = ""; }; @@ -3451,6 +3453,7 @@ F78B87E62B62527100C65ADC /* NCMediaDataSource.swift */, F755CB3F2B8CB13C00CE27E9 /* NCMediaLayout.swift */, F79699E62E689F68000EC82A /* NCMediaNavigationController.swift */, + F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */, F71CFA662F2A07C6007A3AE9 /* NCMediaNetwork.swift */, F7D60CAE2C941ACB008FBFDD /* NCMediaPinchGesture.swift */, F741C2232B6B9FD600E849BB /* NCMediaSelectTabBar.swift */, @@ -5084,6 +5087,7 @@ F7635D8D2FB1F820007F658D /* NCVideoVLCPresenter.swift in Sources */, F749ED312FADD62600CE8DFA /* NCMediaViewerDetailView.swift in Sources */, F768822A2C0DD1E7001CF441 /* NCSettingsModel.swift in Sources */, + F7BCE48E301091EA00876F4D /* NCMediaDatePickerViewController.swift in Sources */, F7A98A522FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift in Sources */, F7103F652FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.swift in Sources */, F737DA9D2B7B893C0063BAFC /* NCPasscode.swift in Sources */, diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 684f1bc63d..45f1f62252 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -33,16 +33,54 @@ extension NCMedia { } func setTitleDate() { - if let layoutAttributes = collectionView.collectionViewLayout.layoutAttributesForElements(in: collectionView.bounds) { - let sortedAttributes = layoutAttributes.sorted { $0.frame.minY < $1.frame.minY || ($0.frame.minY == $1.frame.minY && $0.frame.minX < $1.frame.minX) } + if let pinnedYearMonth { + currentDisplayedYearMonth = pinnedYearMonth - if let firstAttribute = sortedAttributes.first, let metadata = dataSource.getCompactMetadata(indexPath: firstAttribute.indexPath) { - buttonDate.setTitle(utility.getTitleFromDate(metadata.date), for: .normal) - return - } + buttonDate.configuration?.title = formattedTitle( + year: pinnedYearMonth.year, + month: pinnedYearMonth.month + ) + + return + } + + let visibleTop = collectionView.contentOffset.y + + collectionView.adjustedContentInset.top + + guard let layoutAttributes = collectionView.collectionViewLayout + .layoutAttributesForElements(in: collectionView.bounds) else { + buttonDate.configuration?.title = "" + currentDisplayedYearMonth = nil + return + } + + guard let firstVisibleAttribute = layoutAttributes + .filter({ + $0.representedElementCategory == .cell && + $0.frame.maxY > visibleTop + }) + .sorted(by: { + if $0.frame.minY == $1.frame.minY { + return $0.frame.minX < $1.frame.minX + } + + return $0.frame.minY < $1.frame.minY + }) + .first, + let metadata = dataSource.getCompactMetadata( + indexPath: firstVisibleAttribute.indexPath + ) else { + buttonDate.configuration?.title = "" + currentDisplayedYearMonth = nil + return } - buttonDate.setTitle("", for: .normal) + let yearMonth = NCYearMonth(date: metadata.date) + + currentDisplayedYearMonth = yearMonth + + buttonDate.configuration?.title = + utility.getTitleFromDate(metadata.date) } func setElements() { @@ -79,6 +117,91 @@ extension NCMedia { setTitleDate() } + + @IBAction func buttonDateTouchUpInside(_ sender: UIButton) { + presentMediaDatePicker() + } + + private func presentMediaDatePicker() { + let viewController = NCMediaDatePickerViewController( + availableYearMonths: dataSource.availableYearMonths, + selectedYearMonth: currentDisplayedYearMonth + ) + + viewController.onDateSelected = { [weak self] yearMonth in + self?.scrollToMedia( + year: yearMonth.year, + month: yearMonth.month + ) + } + + if let sheet = viewController.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = true + } + + present(viewController, animated: true) + } + + private func currentVisibleYearMonth() -> NCYearMonth? { + guard let layoutAttributes = collectionView.collectionViewLayout + .layoutAttributesForElements(in: collectionView.bounds)? + .sorted(by: { + $0.frame.minY < $1.frame.minY || + ($0.frame.minY == $1.frame.minY && + $0.frame.minX < $1.frame.minX) + }), + let firstAttribute = layoutAttributes.first, + let metadata = dataSource.getCompactMetadata( + indexPath: firstAttribute.indexPath + ) else { + return nil + } + + return NCYearMonth(date: metadata.date) + } + + private func scrollToMedia(year: Int, month: Int) { + guard let indexPath = dataSource.firstIndexPath(year: year, month: month) else { + return + } + let yearMonth = NCYearMonth(year: year, month: month) + + pinnedYearMonth = yearMonth + currentDisplayedYearMonth = yearMonth + + collectionView.layoutIfNeeded() + + guard let attributes = collectionView.collectionViewLayout + .layoutAttributesForItem(at: indexPath) else { + return + } + + let targetOffsetY = attributes.frame.minY - collectionView.adjustedContentInset.top + + collectionView.setContentOffset( + CGPoint( + x: collectionView.contentOffset.x, + y: targetOffsetY + ), + animated: false + ) + + buttonDate.configuration?.title = formattedTitle(year: year, month: month) + } + + private func formattedTitle(year: Int, month: Int) -> String { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + + guard let date = Calendar.current.date(from: components) else { + return "" + } + + return date.formatted(.dateTime .month(.wide) .year()) + } } extension NCMedia: NCMediaSelectTabBarDelegate { diff --git a/iOSClient/Media/NCMedia.storyboard b/iOSClient/Media/NCMedia.storyboard index 4f13484491..593996f4a1 100644 --- a/iOSClient/Media/NCMedia.storyboard +++ b/iOSClient/Media/NCMedia.storyboard @@ -46,6 +46,9 @@ + + + diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 61024c7159..24eda7ed1c 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -61,6 +61,9 @@ class NCMedia: UIViewController { let deltaY: CGFloat } + var pinnedYearMonth: NCYearMonth? + var currentDisplayedYearMonth: NCYearMonth? + @MainActor var session: NCSession.Session { NCSession.shared.getSession(controller: tabBarController) @@ -285,6 +288,7 @@ extension NCMedia: UIScrollViewDelegate { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + pinnedYearMonth = nil } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 10dcb6054c..d1f5d81454 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -533,6 +533,15 @@ public class NCMediaDataSource: NSObject { private(set) var compactMetadatas: [NCCompactMetadata] = [] private(set) var firstIndexByYearMonth: [NCYearMonth: Int] = [:] + var availableYearMonths: [NCYearMonth] { + firstIndexByYearMonth.keys.sorted { + if $0.year == $1.year { + return $0.month > $1.month + } + return $0.year > $1.year + } + } + override init() { super.init() } init(metadatas: [tableMetadata]) { @@ -562,6 +571,7 @@ public class NCMediaDataSource: NSObject { func clearCompactMetadatas() { self.compactMetadatas.removeAll() + self.firstIndexByYearMonth.removeAll() } func isEmpty() -> Bool { @@ -595,10 +605,31 @@ public class NCMediaDataSource: NSObject { return metadatas } - func removeCompactMetadata(_ ocId: [String]) { - self.compactMetadatas.removeAll { item in - ocId.contains(item.ocId) + func removeCompactMetadata(_ ocIds: [String]) { + let ocIds = Set(ocIds) + + compactMetadatas.removeAll { metadata in + ocIds.contains(metadata.ocId) } + + firstIndexByYearMonth = makeFirstIndexByYearMonth( + from: compactMetadatas + ) + } + + func firstIndexPath(year: Int, month: Int) -> IndexPath? { + guard let index = firstIndex( + year: year, + month: month + ) else { + return nil + } + + return IndexPath(item: index, section: 0) + } + + func firstIndex(year: Int, month: Int) -> Int? { + firstIndexByYearMonth[NCYearMonth(year: year, month: month)] } private func makeFirstIndexByYearMonth(from metadatas: [NCCompactMetadata]) -> [NCYearMonth: Int] { @@ -616,10 +647,6 @@ public class NCMediaDataSource: NSObject { return result } - - public func firstIndex(year: Int, month: Int) -> Int? { - firstIndexByYearMonth[NCYearMonth(year: year, month: month)] - } } public struct NCYearMonth: Hashable { diff --git a/iOSClient/Media/NCMediaDatePickerViewController.swift b/iOSClient/Media/NCMediaDatePickerViewController.swift new file mode 100644 index 0000000000..2fc10aaa2d --- /dev/null +++ b/iOSClient/Media/NCMediaDatePickerViewController.swift @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit + +@MainActor +final class NCMediaDatePickerViewController: UIViewController { + var onDateSelected: ((NCYearMonth) -> Void)? + + private let availableYearMonths: [NCYearMonth] + private let selectedYearMonth: NCYearMonth? + + private let pickerView = UIPickerView() + + init( + availableYearMonths: [NCYearMonth], + selectedYearMonth: NCYearMonth? + ) { + self.availableYearMonths = availableYearMonths + self.selectedYearMonth = selectedYearMonth + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + pickerView.dataSource = self + pickerView.delegate = self + pickerView.translatesAutoresizingMaskIntoConstraints = false + + let cancelButton = UIButton(type: .system) + cancelButton.setTitle( + NSLocalizedString("_cancel_", comment: ""), + for: .normal + ) + cancelButton.addTarget( + self, + action: #selector(cancelButtonTouchUpInside), + for: .touchUpInside + ) + + let confirmButton = UIButton(type: .system) + confirmButton.setTitle( + NSLocalizedString("_go_to_", comment: ""), + for: .normal + ) + confirmButton.titleLabel?.font = .systemFont( + ofSize: 17, + weight: .semibold + ) + confirmButton.addTarget( + self, + action: #selector(confirmButtonTouchUpInside), + for: .touchUpInside + ) + + let buttonsStackView = UIStackView( + arrangedSubviews: [ + cancelButton, + UIView(), + confirmButton + ] + ) + buttonsStackView.axis = .horizontal + buttonsStackView.alignment = .center + buttonsStackView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(pickerView) + view.addSubview(buttonsStackView) + + NSLayoutConstraint.activate([ + buttonsStackView.topAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.topAnchor, + constant: 8 + ), + buttonsStackView.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: 20 + ), + buttonsStackView.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -20 + ), + buttonsStackView.heightAnchor.constraint(equalToConstant: 44), + + pickerView.topAnchor.constraint( + equalTo: buttonsStackView.bottomAnchor, + constant: 8 + ), + pickerView.leadingAnchor.constraint( + equalTo: view.leadingAnchor + ), + pickerView.trailingAnchor.constraint( + equalTo: view.trailingAnchor + ), + pickerView.bottomAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.bottomAnchor + ) + ]) + + selectCurrentYearMonth() + } + + private func selectCurrentYearMonth() { + guard let selectedYearMonth, + let row = availableYearMonths.firstIndex( + of: selectedYearMonth + ) else { + return + } + + pickerView.selectRow( + row, + inComponent: 0, + animated: false + ) + } + + @objc + private func cancelButtonTouchUpInside() { + dismiss(animated: true) + } + + @objc + private func confirmButtonTouchUpInside() { + let row = pickerView.selectedRow(inComponent: 0) + + guard availableYearMonths.indices.contains(row) else { + return + } + + let selectedYearMonth = availableYearMonths[row] + + dismiss(animated: true) { [weak self] in + self?.onDateSelected?(selectedYearMonth) + } + } +} + +extension NCMediaDatePickerViewController: UIPickerViewDataSource { + nonisolated func numberOfComponents( + in pickerView: UIPickerView + ) -> Int { + 1 + } + + nonisolated func pickerView( + _ pickerView: UIPickerView, + numberOfRowsInComponent component: Int + ) -> Int { + MainActor.assumeIsolated { + availableYearMonths.count + } + } +} + +extension NCMediaDatePickerViewController: UIPickerViewDelegate { + nonisolated func pickerView( + _ pickerView: UIPickerView, + titleForRow row: Int, + forComponent component: Int + ) -> String? { + MainActor.assumeIsolated { + guard availableYearMonths.indices.contains(row) else { + return nil + } + + let yearMonth = availableYearMonths[row] + + var components = DateComponents() + components.year = yearMonth.year + components.month = yearMonth.month + components.day = 1 + + guard let date = Calendar.current.date( + from: components + ) else { + return nil + } + + return date.formatted( + .dateTime + .month(.wide) + .year() + ) + } + } + + nonisolated func pickerView( + _ pickerView: UIPickerView, + rowHeightForComponent component: Int + ) -> CGFloat { + 44 + } +} From a343b069b039b715352fe47ecd3d9970c0b908dd Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 09:19:21 +0200 Subject: [PATCH 04/22] fix: show visible date range in media header Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+Command.swift | 92 +++++++-------------------- iOSClient/Media/NCMedia.swift | 12 ++-- 2 files changed, 31 insertions(+), 73 deletions(-) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 45f1f62252..15991a823d 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -33,54 +33,29 @@ extension NCMedia { } func setTitleDate() { - if let pinnedYearMonth { - currentDisplayedYearMonth = pinnedYearMonth - - buttonDate.configuration?.title = formattedTitle( - year: pinnedYearMonth.year, - month: pinnedYearMonth.month - ) - - return - } - - let visibleTop = collectionView.contentOffset.y - + collectionView.adjustedContentInset.top - - guard let layoutAttributes = collectionView.collectionViewLayout - .layoutAttributesForElements(in: collectionView.bounds) else { - buttonDate.configuration?.title = "" - currentDisplayedYearMonth = nil - return + let visibleIndexPaths = collectionView.indexPathsForVisibleItems.sorted { + $0.item < $1.item } - guard let firstVisibleAttribute = layoutAttributes - .filter({ - $0.representedElementCategory == .cell && - $0.frame.maxY > visibleTop - }) - .sorted(by: { - if $0.frame.minY == $1.frame.minY { - return $0.frame.minX < $1.frame.minX - } - - return $0.frame.minY < $1.frame.minY - }) - .first, - let metadata = dataSource.getCompactMetadata( - indexPath: firstVisibleAttribute.indexPath - ) else { + guard let firstIndexPath = visibleIndexPaths.first, + let lastIndexPath = visibleIndexPaths.last, + let firstMetadata = dataSource.getCompactMetadata(indexPath: firstIndexPath), + let lastMetadata = dataSource.getCompactMetadata(indexPath: lastIndexPath) else { buttonDate.configuration?.title = "" - currentDisplayedYearMonth = nil + buttonDate.configuration?.subtitle = nil + buttonDate.configuration?.image = nil + buttonDate.isEnabled = false return } - let yearMonth = NCYearMonth(date: metadata.date) + let firstDateTitle = utility.getTitleFromDate(firstMetadata.date) + let lastDateTitle = utility.getTitleFromDate(lastMetadata.date) + let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - currentDisplayedYearMonth = yearMonth - - buttonDate.configuration?.title = - utility.getTitleFromDate(metadata.date) + buttonDate.configuration?.title = firstDateTitle + buttonDate.configuration?.subtitle = firstDateTitle == lastDateTitle ? nil : lastDateTitle + buttonDate.configuration?.image = UIImage(systemName: "chevron.down", withConfiguration: symbolConfiguration) + buttonDate.isEnabled = true } func setElements() { @@ -125,14 +100,11 @@ extension NCMedia { private func presentMediaDatePicker() { let viewController = NCMediaDatePickerViewController( availableYearMonths: dataSource.availableYearMonths, - selectedYearMonth: currentDisplayedYearMonth + selectedYearMonth: currentVisibleYearMonth() ) viewController.onDateSelected = { [weak self] yearMonth in - self?.scrollToMedia( - year: yearMonth.year, - month: yearMonth.month - ) + self?.scrollToMedia(year: yearMonth.year, month: yearMonth.month) } if let sheet = viewController.sheetPresentationController { @@ -144,17 +116,8 @@ extension NCMedia { } private func currentVisibleYearMonth() -> NCYearMonth? { - guard let layoutAttributes = collectionView.collectionViewLayout - .layoutAttributesForElements(in: collectionView.bounds)? - .sorted(by: { - $0.frame.minY < $1.frame.minY || - ($0.frame.minY == $1.frame.minY && - $0.frame.minX < $1.frame.minX) - }), - let firstAttribute = layoutAttributes.first, - let metadata = dataSource.getCompactMetadata( - indexPath: firstAttribute.indexPath - ) else { + guard let firstIndexPath = collectionView.indexPathsForVisibleItems.min(by: { $0.item < $1.item }), + let metadata = dataSource.getCompactMetadata(indexPath: firstIndexPath) else { return nil } @@ -165,29 +128,22 @@ extension NCMedia { guard let indexPath = dataSource.firstIndexPath(year: year, month: month) else { return } - let yearMonth = NCYearMonth(year: year, month: month) - - pinnedYearMonth = yearMonth - currentDisplayedYearMonth = yearMonth collectionView.layoutIfNeeded() - guard let attributes = collectionView.collectionViewLayout - .layoutAttributesForItem(at: indexPath) else { + guard let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else { return } let targetOffsetY = attributes.frame.minY - collectionView.adjustedContentInset.top collectionView.setContentOffset( - CGPoint( - x: collectionView.contentOffset.x, - y: targetOffsetY - ), + CGPoint(x: collectionView.contentOffset.x, y: targetOffsetY), animated: false ) - buttonDate.configuration?.title = formattedTitle(year: year, month: month) + collectionView.layoutIfNeeded() + setTitleDate() } private func formattedTitle(year: Int, month: Int) -> String { diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 24eda7ed1c..eaf1b065e4 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -61,9 +61,6 @@ class NCMedia: UIViewController { let deltaY: CGFloat } - var pinnedYearMonth: NCYearMonth? - var currentDisplayedYearMonth: NCYearMonth? - @MainActor var session: NCSession.Session { NCSession.shared.getSession(controller: tabBarController) @@ -144,9 +141,15 @@ class NCMedia: UIViewController { configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming - outgoing.font = UIFont.systemFont(ofSize: 20, weight: .semibold) + outgoing.font = UIFont.systemFont(ofSize: 17, weight: .semibold) return outgoing } + configuration.subtitleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + return outgoing + } + configuration.titlePadding = 1 buttonDate.configuration = configuration activityIndicator.color = .white @@ -288,7 +291,6 @@ extension NCMedia: UIScrollViewDelegate { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - pinnedYearMonth = nil } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { From f87a4c536b573a91da6e87b1751542510196e58a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 09:53:25 +0200 Subject: [PATCH 05/22] refactor: migrate media date picker to SwiftUI Use a compact custom sheet and apply date selections as the picker changes. Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+Command.swift | 7 +- .../NCMediaDatePickerViewController.swift | 284 ++++++++---------- 2 files changed, 131 insertions(+), 160 deletions(-) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 15991a823d..9662183768 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -108,8 +108,13 @@ extension NCMedia { } if let sheet = viewController.sheetPresentationController { - sheet.detents = [.medium()] + sheet.detents = [ + .custom(identifier: .init("mediaDatePicker")) { _ in + 200 + } + ] sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false } present(viewController, animated: true) diff --git a/iOSClient/Media/NCMediaDatePickerViewController.swift b/iOSClient/Media/NCMediaDatePickerViewController.swift index 2fc10aaa2d..4b6600f61b 100644 --- a/iOSClient/Media/NCMediaDatePickerViewController.swift +++ b/iOSClient/Media/NCMediaDatePickerViewController.swift @@ -2,202 +2,168 @@ // SPDX-FileCopyrightText: 2026 Marino Faggiana // SPDX-License-Identifier: GPL-3.0-or-later +import SwiftUI import UIKit @MainActor -final class NCMediaDatePickerViewController: UIViewController { - var onDateSelected: ((NCYearMonth) -> Void)? - - private let availableYearMonths: [NCYearMonth] - private let selectedYearMonth: NCYearMonth? +final class NCMediaDatePickerViewController: UIHostingController { + private let model: NCMediaDatePickerModel - private let pickerView = UIPickerView() + var onDateSelected: ((NCYearMonth) -> Void)? { + get { + model.onDateSelected + } + set { + model.onDateSelected = newValue + } + } init( availableYearMonths: [NCYearMonth], selectedYearMonth: NCYearMonth? ) { - self.availableYearMonths = availableYearMonths - self.selectedYearMonth = selectedYearMonth + let model = NCMediaDatePickerModel( + availableYearMonths: availableYearMonths, + selectedYearMonth: selectedYearMonth + ) + + self.model = model + + super.init( + rootView: NCMediaDatePickerView(model: model) + ) - super.init(nibName: nil, bundle: nil) + view.backgroundColor = .clear } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground +// MARK: - Model - pickerView.dataSource = self - pickerView.delegate = self - pickerView.translatesAutoresizingMaskIntoConstraints = false +@MainActor +final class NCMediaDatePickerModel: ObservableObject { + let availableYearMonths: [NCYearMonth] - let cancelButton = UIButton(type: .system) - cancelButton.setTitle( - NSLocalizedString("_cancel_", comment: ""), - for: .normal - ) - cancelButton.addTarget( - self, - action: #selector(cancelButtonTouchUpInside), - for: .touchUpInside - ) + @Published var selectedYearMonth: NCYearMonth? - let confirmButton = UIButton(type: .system) - confirmButton.setTitle( - NSLocalizedString("_go_to_", comment: ""), - for: .normal - ) - confirmButton.titleLabel?.font = .systemFont( - ofSize: 17, - weight: .semibold - ) - confirmButton.addTarget( - self, - action: #selector(confirmButtonTouchUpInside), - for: .touchUpInside - ) + var onDateSelected: ((NCYearMonth) -> Void)? - let buttonsStackView = UIStackView( - arrangedSubviews: [ - cancelButton, - UIView(), - confirmButton - ] - ) - buttonsStackView.axis = .horizontal - buttonsStackView.alignment = .center - buttonsStackView.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(pickerView) - view.addSubview(buttonsStackView) - - NSLayoutConstraint.activate([ - buttonsStackView.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: 8 - ), - buttonsStackView.leadingAnchor.constraint( - equalTo: view.leadingAnchor, - constant: 20 - ), - buttonsStackView.trailingAnchor.constraint( - equalTo: view.trailingAnchor, - constant: -20 - ), - buttonsStackView.heightAnchor.constraint(equalToConstant: 44), - - pickerView.topAnchor.constraint( - equalTo: buttonsStackView.bottomAnchor, - constant: 8 - ), - pickerView.leadingAnchor.constraint( - equalTo: view.leadingAnchor - ), - pickerView.trailingAnchor.constraint( - equalTo: view.trailingAnchor - ), - pickerView.bottomAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.bottomAnchor - ) - ]) - - selectCurrentYearMonth() - } + init( + availableYearMonths: [NCYearMonth], + selectedYearMonth: NCYearMonth? + ) { + self.availableYearMonths = availableYearMonths - private func selectCurrentYearMonth() { - guard let selectedYearMonth, - let row = availableYearMonths.firstIndex( - of: selectedYearMonth - ) else { - return + if let selectedYearMonth, + availableYearMonths.contains(selectedYearMonth) { + self.selectedYearMonth = selectedYearMonth + } else { + self.selectedYearMonth = availableYearMonths.first } - - pickerView.selectRow( - row, - inComponent: 0, - animated: false - ) - } - - @objc - private func cancelButtonTouchUpInside() { - dismiss(animated: true) } - @objc - private func confirmButtonTouchUpInside() { - let row = pickerView.selectedRow(inComponent: 0) + func title(for yearMonth: NCYearMonth) -> String { + var components = DateComponents() + components.year = yearMonth.year + components.month = yearMonth.month + components.day = 1 - guard availableYearMonths.indices.contains(row) else { - return + guard let date = Calendar.current.date(from: components) else { + return "\(yearMonth.month) \(yearMonth.year)" } - let selectedYearMonth = availableYearMonths[row] - - dismiss(animated: true) { [weak self] in - self?.onDateSelected?(selectedYearMonth) - } + return date.formatted( + .dateTime + .month(.wide) + .year() + ) } } -extension NCMediaDatePickerViewController: UIPickerViewDataSource { - nonisolated func numberOfComponents( - in pickerView: UIPickerView - ) -> Int { - 1 - } +// MARK: - View - nonisolated func pickerView( - _ pickerView: UIPickerView, - numberOfRowsInComponent component: Int - ) -> Int { - MainActor.assumeIsolated { - availableYearMonths.count - } - } -} - -extension NCMediaDatePickerViewController: UIPickerViewDelegate { - nonisolated func pickerView( - _ pickerView: UIPickerView, - titleForRow row: Int, - forComponent component: Int - ) -> String? { - MainActor.assumeIsolated { - guard availableYearMonths.indices.contains(row) else { - return nil - } +struct NCMediaDatePickerView: View { + @ObservedObject var model: NCMediaDatePickerModel - let yearMonth = availableYearMonths[row] + @Environment(\.dismiss) private var dismiss - var components = DateComponents() - components.year = yearMonth.year - components.month = yearMonth.month - components.day = 1 + var body: some View { + VStack(spacing: 0) { + HStack { + Spacer() - guard let date = Calendar.current.date( - from: components - ) else { - return nil + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .medium)) + .frame(width: 24, height: 24) + } + .modifier(NCMediaCloseButtonStyle()) + .frame(width: 44, height: 44) + .accessibilityLabel(Text(NSLocalizedString("_close_", comment: ""))) + } + .padding(.horizontal, 12) + .padding(.top, 4) + + Picker("", selection: $model.selectedYearMonth) { + ForEach(model.availableYearMonths, id: \.self) { yearMonth in + Text(model.title(for: yearMonth)) + .tag(Optional(yearMonth)) + } + } + .pickerStyle(.wheel) + .labelsHidden() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + .clipped() + .onChange(of: model.selectedYearMonth) { _, selectedYearMonth in + guard let selectedYearMonth else { + return + } + + model.onDateSelected?(selectedYearMonth) } - - return date.formatted( - .dateTime - .month(.wide) - .year() - ) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - nonisolated func pickerView( - _ pickerView: UIPickerView, - rowHeightForComponent component: Int - ) -> CGFloat { - 44 + private struct NCMediaCloseButtonStyle: ViewModifier { + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } else { + content + .buttonStyle(.plain) + } + } } } + +#if DEBUG +#Preview("Media Date Picker") { + NCMediaDatePickerView( + model: NCMediaDatePickerModel( + availableYearMonths: [ + NCYearMonth(year: 2026, month: 7), + NCYearMonth(year: 2026, month: 6), + NCYearMonth(year: 2026, month: 5), + NCYearMonth(year: 2026, month: 4), + NCYearMonth(year: 2026, month: 3), + NCYearMonth(year: 2026, month: 2), + NCYearMonth(year: 2026, month: 1), + NCYearMonth(year: 2025, month: 12), + NCYearMonth(year: 2025, month: 11) + ], + selectedYearMonth: NCYearMonth(year: 2026, month: 7) + ) + ) + .frame(height: 340) +} +#endif From da3d2f6af5af6761caa3d40f05da2c3f0252d6f6 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 10:07:51 +0200 Subject: [PATCH 06/22] feat: add title to media date picker Signed-off-by: Marino Faggiana --- .../NCMediaDatePickerViewController.swift | 58 +++++++++++-------- .../en.lproj/Localizable.strings | 1 + 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/iOSClient/Media/NCMediaDatePickerViewController.swift b/iOSClient/Media/NCMediaDatePickerViewController.swift index 4b6600f61b..06f88daf13 100644 --- a/iOSClient/Media/NCMediaDatePickerViewController.swift +++ b/iOSClient/Media/NCMediaDatePickerViewController.swift @@ -92,22 +92,30 @@ struct NCMediaDatePickerView: View { var body: some View { VStack(spacing: 0) { - HStack { - Spacer() - - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .medium)) - .frame(width: 24, height: 24) + ZStack { + Text(NSLocalizedString("_select_date_", comment: "")) + .font(.system(size: 17, weight: .semibold)) + .lineLimit(1) + + HStack { + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 24, height: 24) + } + .modifier(NCMediaCloseButtonStyle()) + .frame(width: 44, height: 44) + .accessibilityLabel( + Text(NSLocalizedString("_close_", comment: "")) + ) } - .modifier(NCMediaCloseButtonStyle()) - .frame(width: 44, height: 44) - .accessibilityLabel(Text(NSLocalizedString("_close_", comment: ""))) } - .padding(.horizontal, 12) - .padding(.top, 4) + .frame(height: 52) + .padding(.horizontal, 16) Picker("", selection: $model.selectedYearMonth) { ForEach(model.availableYearMonths, id: \.self) { yearMonth in @@ -130,18 +138,18 @@ struct NCMediaDatePickerView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } +} - private struct NCMediaCloseButtonStyle: ViewModifier { - @ViewBuilder - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content - .buttonStyle(.glass) - .buttonBorderShape(.circle) - } else { - content - .buttonStyle(.plain) - } +private struct NCMediaCloseButtonStyle: ViewModifier { + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } else { + content + .buttonStyle(.plain) } } } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 6aa75c1854..4565205b56 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -707,6 +707,7 @@ "_media_not_available_" = "Media not available"; "_no_assistant_installed_" = "Assistant is not installed on this server. Ask your administrator to install the Assistant app"; "_open_in_office_" = "Open in Office"; +"_select_date_" = "Select date"; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; From 5ea946bf31892339cb2853354a399206ada76f42 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 11:22:16 +0200 Subject: [PATCH 07/22] refactor: move media date range to navigation bar Remove the media header gradient and activity indicator, and format the visible date range as a navigation bar item. Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+Command.swift | 102 ++++++++++++------------ iOSClient/Media/NCMedia.storyboard | 29 ------- iOSClient/Media/NCMedia.swift | 41 +++------- iOSClient/Media/NCMediaDataSource.swift | 10 +-- 4 files changed, 65 insertions(+), 117 deletions(-) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 9662183768..42d3ca6712 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -41,63 +41,67 @@ extension NCMedia { let lastIndexPath = visibleIndexPaths.last, let firstMetadata = dataSource.getCompactMetadata(indexPath: firstIndexPath), let lastMetadata = dataSource.getCompactMetadata(indexPath: lastIndexPath) else { - buttonDate.configuration?.title = "" - buttonDate.configuration?.subtitle = nil - buttonDate.configuration?.image = nil - buttonDate.isEnabled = false + navigationItem.leftBarButtonItem = nil return } - let firstDateTitle = utility.getTitleFromDate(firstMetadata.date) - let lastDateTitle = utility.getTitleFromDate(lastMetadata.date) - let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - - buttonDate.configuration?.title = firstDateTitle - buttonDate.configuration?.subtitle = firstDateTitle == lastDateTitle ? nil : lastDateTitle - buttonDate.configuration?.image = UIImage(systemName: "chevron.down", withConfiguration: symbolConfiguration) - buttonDate.isEnabled = true - } - - func setElements() { - let highTextTitle = buttonDate.frame.height - let isOver = - collectionView.contentOffset.y + highTextTitle <= -view.safeAreaInsets.top && - collectionView.contentOffset.y != -view.safeAreaInsets.top - - let shouldHideGradient = isOver || dataSource.compactMetadatas.isEmpty - let foregroundColor: UIColor = shouldHideGradient ? NCBrandColor.shared.textColor : .white - var configuration = buttonDate.configuration - configuration?.baseForegroundColor = foregroundColor - buttonDate.configuration = configuration - activityIndicator.color = foregroundColor - - if #unavailable(iOS 26.0) { - (navigationController as? NCMediaNavigationController)? - .updateRightBarButtonsTint(to: foregroundColor) - } - - if !shouldHideGradient { - gradientView.isHidden = false + let firstDate = firstMetadata.date + let lastDate = lastMetadata.date + let calendar = Calendar.current + + let firstYear = calendar.component(.year, from: firstDate) + let lastYear = calendar.component(.year, from: lastDate) + + let title: String + + if calendar.isDate(firstDate, inSameDayAs: lastDate) { + title = firstDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + } else if firstYear == lastYear { + let firstDateTitle = firstDate.formatted( + .dateTime + .day() + .month(.abbreviated) + ) + + let lastDateTitle = lastDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + + title = "\(firstDateTitle) – \(lastDateTitle)" + } else { + let firstDateTitle = firstDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + + let lastDateTitle = lastDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + + title = "\(firstDateTitle) – \(lastDateTitle)" } - UIView.animate( - withDuration: 0.3, - animations: { - self.gradientView.alpha = shouldHideGradient ? 0 : 1 - }, - completion: { _ in - self.gradientView.isHidden = shouldHideGradient - } - ) - - setTitleDate() - } + buttonDateBarItem.title = title - @IBAction func buttonDateTouchUpInside(_ sender: UIButton) { - presentMediaDatePicker() + if navigationItem.leftBarButtonItem !== buttonDateBarItem { + navigationItem.leftBarButtonItem = buttonDateBarItem + } } - private func presentMediaDatePicker() { + @objc func presentMediaDatePicker() { let viewController = NCMediaDatePickerViewController( availableYearMonths: dataSource.availableYearMonths, selectedYearMonth: currentVisibleYearMonth() diff --git a/iOSClient/Media/NCMedia.storyboard b/iOSClient/Media/NCMedia.storyboard index 593996f4a1..f43f78dea1 100644 --- a/iOSClient/Media/NCMedia.storyboard +++ b/iOSClient/Media/NCMedia.storyboard @@ -31,46 +31,17 @@ - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index eaf1b065e4..3942bd8203 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -9,9 +9,6 @@ import RealmSwift class NCMedia: UIViewController { @IBOutlet weak var collectionView: UICollectionView! - @IBOutlet weak var buttonDate: UIButton! - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - @IBOutlet weak var gradientView: UIView! let layout = NCMediaLayout() let gradientLayer = CAGradientLayer() @@ -61,6 +58,13 @@ class NCMedia: UIViewController { let deltaY: CGFloat } + internal lazy var buttonDateBarItem = UIBarButtonItem( + title: nil, + style: .plain, + target: self, + action: #selector(presentMediaDatePicker) + ) + @MainActor var session: NCSession.Session { NCSession.shared.getSession(controller: tabBarController) @@ -128,31 +132,8 @@ class NCMedia: UIViewController { UIColor.clear.cgColor ] - gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] - gradientView.layer.insertSublayer(gradientLayer, at: 0) - - var configuration = buttonDate.configuration ?? UIButton.Configuration.plain() - configuration.title = "" - let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - configuration.image = UIImage(systemName: "chevron.down", withConfiguration: symbolConfiguration) - configuration.imagePlacement = .trailing - configuration.imagePadding = 8 - configuration.baseForegroundColor = .white - configuration.titleTextAttributesTransformer = - UIConfigurationTextAttributesTransformer { incoming in - var outgoing = incoming - outgoing.font = UIFont.systemFont(ofSize: 17, weight: .semibold) - return outgoing - } - configuration.subtitleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in - var outgoing = incoming - outgoing.font = UIFont.systemFont(ofSize: 17, weight: .semibold) - return outgoing - } - configuration.titlePadding = 1 - buttonDate.configuration = configuration - - activityIndicator.color = .white + navigationItem.leftItemsSupplementBackButton = true + navigationItem.leftBarButtonItem = nil pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) collectionView.addGestureRecognizer(pinchGesture) @@ -230,8 +211,6 @@ class NCMedia: UIViewController { override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - - gradientLayer.frame = gradientView.bounds } func searchNewMedia() { @@ -287,7 +266,7 @@ extension NCMedia: UIScrollViewDelegate { setTitleDate() setNeedsStatusBarAppearanceUpdate() } - setElements() + setTitleDate() } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index d1f5d81454..7aff6c0b73 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -66,7 +66,7 @@ extension NCMedia { @MainActor func collectionViewReloadData() { collectionView.reloadData() - setElements() + setTitleDate() } // MARK: - Keeping position @@ -161,7 +161,7 @@ extension NCMedia { self.collectionView.layoutIfNeeded() self.restoreScrollAnchor(anchor) - self.setElements() + self.setTitleDate() } } @@ -178,7 +178,6 @@ extension NCMedia { return false } self.searchMediaInProgress = true - self.activityIndicator.startAnimating() return true } @@ -189,7 +188,6 @@ extension NCMedia { guard let tblAccount = await self.database.getTableAccountAsync(predicate: NSPredicate(format: "account == %@", account)) else { await MainActor.run { - self.activityIndicator.stopAnimating() self.searchMediaInProgress = false } return @@ -259,7 +257,6 @@ extension NCMedia { guard self.session.account == account else { await MainActor.run { - self.activityIndicator.stopAnimating() self.searchMediaInProgress = false } return @@ -282,7 +279,6 @@ extension NCMedia { } guard self.session.account == account else { await MainActor.run { - self.activityIndicator.stopAnimating() self.searchMediaInProgress = false } return @@ -290,7 +286,6 @@ extension NCMedia { guard let firstDate, let lastDate else { Task { @MainActor in - self.activityIndicator.stopAnimating() self.searchMediaInProgress = false } return @@ -316,7 +311,6 @@ extension NCMedia { } } finish: { Task { @MainActor in - self.activityIndicator.stopAnimating() self.searchMediaInProgress = false } } From de264aa1e6fe089ff897c395cb30868e5527e474 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 15:52:06 +0200 Subject: [PATCH 08/22] fix: correct media collection layout calculations Use safe column counts and collection view bounds, calculate content height across all columns, and rebuild accurate union and footer geometry. Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMediaLayout.swift | 83 +++++++++++++++++++---------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/iOSClient/Media/NCMediaLayout.swift b/iOSClient/Media/NCMediaLayout.swift index 4cfce7d413..2ba15ecb28 100644 --- a/iOSClient/Media/NCMediaLayout.swift +++ b/iOSClient/Media/NCMediaLayout.swift @@ -29,11 +29,7 @@ public class NCMediaLayout: UICollectionViewLayout { private let unionSize = 20 // MARK: - Public Properties - public var columnCount: Int = 0 { - didSet { - invalidateIfNotEqual(oldValue, newValue: columnCount) - } - } + public private(set) var columnCount: Int = 1 public var minimumColumnSpacing: Float = 1.0 { didSet { invalidateIfNotEqual(oldValue, newValue: minimumColumnSpacing) @@ -70,14 +66,14 @@ public class NCMediaLayout: UICollectionViewLayout { } } public override var collectionViewContentSize: CGSize { - let numberOfSections = collectionView?.numberOfSections - if numberOfSections == 0 { - return CGSize.zero + guard let collectionView else { + return .zero } - var contentSize = collectionView?.bounds.size - contentSize?.height = CGFloat(columnHeights[0]) - return contentSize! + return CGSize( + width: collectionView.bounds.width, + height: CGFloat(columnHeights.max() ?? 0) + ) } public var frameWidth: Float = 0 public var itemWidth: Float = 0 @@ -101,8 +97,11 @@ public class NCMediaLayout: UICollectionViewLayout { let collectionView = collectionView, let delegate = delegate else { return } - columnCount = delegate.getColumnCount() - (delegate as? NCMedia)?.buildMediaPhotoVideo(columnCount: columnCount) + let resolvedColumnCount = max(1, delegate.getColumnCount()) + + if columnCount != resolvedColumnCount { + columnCount = resolvedColumnCount + } // Initialize variables headersAttribute.removeAll(keepingCapacity: false) @@ -126,7 +125,7 @@ public class NCMediaLayout: UICollectionViewLayout { */ let minimumInteritemSpacing: Float = delegate.collectionView(collectionView, layout: self, minimumInteritemSpacingForSection: section) let sectionInset: UIEdgeInsets = delegate.collectionView(collectionView, layout: self, insetForSection: section) - frameWidth = Float(collectionView.frame.size.width - sectionInset.left - sectionInset.right) + frameWidth = Float(collectionView.bounds.width - sectionInset.left - sectionInset.right) itemWidth = ((frameWidth - Float(columnCount - 1) * Float(minimumColumnSpacing)) / Float(columnCount)) /* @@ -139,7 +138,7 @@ public class NCMediaLayout: UICollectionViewLayout { if headerHeight > 0 { attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: mediaSectionHeader, with: NSIndexPath(item: 0, section: section) as IndexPath) - attributes.frame = CGRect(x: headerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight)) + attributes.frame = CGRect(x: headerInset.left, y: CGFloat(top), width: collectionView.bounds.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight)) headersAttribute[section] = attributes allItemAttributes.append(attributes) @@ -167,7 +166,9 @@ public class NCMediaLayout: UICollectionViewLayout { if UIView.userInterfaceLayoutDirection(for: collectionView.semanticContentAttribute) == .leftToRight { xOffset = Float(sectionInset.left) + Float(itemWidth + minimumColumnSpacing) * Float(columnIndex) } else { - xOffset = Float(collectionView.frame.width) - Float(sectionInset.right) - Float(itemWidth + minimumColumnSpacing) * Float(columnIndex + 1) + xOffset = Float(collectionView.bounds.width) + - Float(sectionInset.right) + - Float(itemWidth + minimumColumnSpacing) * Float(columnIndex + 1) } let yOffset = columnHeights[columnIndex] @@ -191,13 +192,30 @@ public class NCMediaLayout: UICollectionViewLayout { * 4. Section footer */ let columnIndex = longestColumnIndex() - top = columnHeights[columnIndex] - minimumInteritemSpacing + Float(sectionInset.bottom) + top = columnHeights[columnIndex] + + if itemCount > 0 { + top -= minimumInteritemSpacing + } + + top += Float(sectionInset.bottom) + let footerInset = delegate.collectionView( + collectionView, + layout: self, + insetForFooterInSection: section + ) + top += Float(footerInset.top) - let footerHeight = delegate.collectionView(collectionView, layout: self, heightForFooterInSection: section) + + let footerHeight = delegate.collectionView( + collectionView, + layout: self, + heightForFooterInSection: section + ) if footerHeight > 0 { attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: mediaSectionFooter, with: NSIndexPath(item: 0, section: section) as IndexPath) - attributes.frame = CGRect(x: footerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (footerInset.left + footerInset.right), height: CGFloat(footerHeight)) + attributes.frame = CGRect(x: footerInset.left, y: CGFloat(top), width: collectionView.bounds.width - (footerInset.left + footerInset.right), height: CGFloat(footerHeight)) footersAttribute[section] = attributes allItemAttributes.append(attributes) @@ -211,15 +229,24 @@ public class NCMediaLayout: UICollectionViewLayout { } // Build union rects - var idx = 0 - let itemCounts = allItemAttributes.count - - while idx < itemCounts { - let rect1 = allItemAttributes[idx].frame - idx = min(idx + unionSize, itemCounts) - 1 - let rect2 = allItemAttributes[idx].frame - unionRects.append(rect1.union(rect2)) - idx += 1 + unionRects.removeAll(keepingCapacity: true) + + var startIndex = 0 + + while startIndex < allItemAttributes.count { + let endIndex = min( + startIndex + unionSize, + allItemAttributes.count + ) + + var unionRect = allItemAttributes[startIndex].frame + + for index in (startIndex + 1).. Date: Wed, 22 Jul 2026 16:16:53 +0200 Subject: [PATCH 09/22] fix: improve media grid updates and viewer selection Refresh the media layout when pinch gestures change column counts, use hydrated metadata for previews, and guard asynchronous viewer selection against reused cells. Signed-off-by: Marino Faggiana --- .../NCMedia+CollectionViewDataSource.swift | 26 +++--- .../NCMedia+CollectionViewDelegate.swift | 84 ++++++++++++++----- iOSClient/Media/NCMedia+MediaLayout.swift | 22 +++-- iOSClient/Media/NCMedia.swift | 37 ++++---- iOSClient/Media/NCMediaPinchGesture.swift | 36 +++++--- 5 files changed, 136 insertions(+), 69 deletions(-) diff --git a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift index e07bbe3edf..210762c6ca 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift @@ -29,9 +29,7 @@ extension NCMedia: UICollectionViewDataSource { } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - let numberOfItemsInSection = dataSource.compactMetadatas.count - self.numberOfColumns = getColumnCount() - return numberOfItemsInSection + dataSource.compactMetadatas.count } func collectionView(_ collectionView: UICollectionView, @@ -65,27 +63,33 @@ extension NCMedia: UICollectionViewDataSource { identifier: ocId, priority: .visible ) { - guard let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) else { + guard var metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) else { return } - let iconName = metadata.iconName - let account = metadata.account - // Retrieves and stores complete metadata when the media record is a placeholder. if metadata.placeholder { - let result = await self.networking.readFileAsync(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) + let result = await self.networking.readFileAsync( + serverUrlFileName: metadata.serverUrlFileName, + account: metadata.account + ) + guard !Task.isCancelled, result.error == .success, - let metadata = result.metadata else { + let hydratedMetadata = result.metadata else { return } - await self.database.addMetadataAsync(metadata) + + await self.database.addMetadataAsync(hydratedMetadata) + metadata = hydratedMetadata } + let iconName = metadata.iconName + let account = metadata.account + let result = await NextcloudKit.shared.downloadPreviewAsync( fileId: metadata.fileId, etag: metadata.etag, - account: account + account: metadata.account ) guard !Task.isCancelled, diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index a3d8ed59f1..cae23d2c9b 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -8,35 +8,79 @@ import RealmSwift extension NCMedia: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - Task { - guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath), - let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell else { return } + Task { @MainActor in + guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath) else { + return + } if isEditMode { + guard let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell else { + return + } + if let index = fileSelect.firstIndex(of: compactMetadata.ocId) { fileSelect.remove(at: index) - cell.selected(false, color: NCBrandColor.shared.getElement(account: session.account)) + cell.selected( + false, + color: NCBrandColor.shared.getElement(account: session.account) + ) } else { fileSelect.append(compactMetadata.ocId) - cell.selected(true, color: NCBrandColor.shared.getElement(account: session.account)) + cell.selected( + true, + color: NCBrandColor.shared.getElement(account: session.account) + ) } + tabBarSelect.selectCount = fileSelect.count - } else if let metadata = await self.database.getMetadataFromOcIdAsync(compactMetadata.ocId) { - let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - var viewerTransitionSource: NCMediaViewerTransitionSource? - let ocIds = dataSource.compactMetadatas.map { $0.ocId } - - if let imageView = cell.image, - let image = imageView.image, - let window = imageView.window { - let sourceFrame = imageView.convert(imageView.bounds, to: window) - viewerTransitionSource = NCMediaViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) - } + return + } - if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: ocIds, image: image, delegate: self, viewerTransitionSource: viewerTransitionSource) { - vc.view.backgroundColor = .clear - self.navigationController?.pushViewController(vc, animated: false) - } + guard let metadata = await database.getMetadataFromOcIdAsync(compactMetadata.ocId), + dataSource.getCompactMetadata(indexPath: indexPath)?.ocId == compactMetadata.ocId else { + return + } + + let image = utility.getImage( + ocId: metadata.ocId, + etag: metadata.etag, + ext: global.previewExt1024, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + var viewerTransitionSource: NCMediaViewerTransitionSource? + + if let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell, + let imageView = cell.image, + let transitionImage = imageView.image, + let window = imageView.window { + let sourceFrame = imageView.convert( + imageView.bounds, + to: window + ) + + viewerTransitionSource = NCMediaViewerTransitionSource( + image: transitionImage, + sourceFrame: sourceFrame, + cornerRadius: imageView.layer.cornerRadius + ) + } + + let ocIds = dataSource.compactMetadatas.map(\.ocId) + + if let viewController = await NCViewer().getViewerController( + metadata: metadata, + ocIds: ocIds, + image: image, + delegate: self, + viewerTransitionSource: viewerTransitionSource + ) { + viewController.view.backgroundColor = .clear + navigationController?.pushViewController( + viewController, + animated: false + ) } } } diff --git a/iOSClient/Media/NCMedia+MediaLayout.swift b/iOSClient/Media/NCMedia+MediaLayout.swift index 9422822684..2b5ddd7f4d 100644 --- a/iOSClient/Media/NCMedia+MediaLayout.swift +++ b/iOSClient/Media/NCMedia+MediaLayout.swift @@ -56,16 +56,20 @@ extension NCMedia: NCMediaLayoutDelegate { } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: IndexPath, columnCount: Int, typeLayout: String) -> CGSize { - if typeLayout == global.mediaLayoutSquare { - return CGSize(width: collectionView.frame.width / CGFloat(columnCount), height: collectionView.frame.width / CGFloat(columnCount)) - } else { - guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath) else { return .zero } + let defaultSize = CGSize( + width: collectionView.bounds.width / CGFloat(columnCount), + height: collectionView.bounds.width / CGFloat(columnCount) + ) - if compactMetadata.imageSize != CGSize.zero { - return compactMetadata.imageSize - } else { - return CGSize(width: collectionView.frame.width / CGFloat(columnCount), height: collectionView.frame.width / CGFloat(columnCount)) - } + guard typeLayout != global.mediaLayoutSquare else { + return defaultSize } + + guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath), + compactMetadata.imageSize != .zero else { + return defaultSize + } + + return compactMetadata.imageSize } } diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 3942bd8203..ef107f5384 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -138,7 +138,11 @@ class NCMedia: UIViewController { pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) collectionView.addGestureRecognizer(pinchGesture) - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { notification in + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { [weak self] notification in + guard let self else { + return + } + Task { @MainActor in guard let userInfo = notification.userInfo, let account = userInfo["account"] as? String else { @@ -152,7 +156,11 @@ class NCMedia: UIViewController { } } - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { [weak self] _ in + guard let self else { + return + } + Task { await self.dataSource.clearCompactMetadatas() self.imageCache.removeAll() @@ -160,7 +168,11 @@ class NCMedia: UIViewController { } } - NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + guard let self else { + return + } + Task { await self.networkRemoveAll() } @@ -209,10 +221,6 @@ class NCMedia: UIViewController { NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) } - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - } - func searchNewMedia() { timerSearchNewMedia?.invalidate() timerSearchNewMedia = Timer.scheduledTimer(withTimeInterval: timeIntervalSearchNewMedia, repeats: false) { [weak self] _ in @@ -262,29 +270,22 @@ class NCMedia: UIViewController { extension NCMedia: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { + setTitleDate() + if !dataSource.compactMetadatas.isEmpty { - setTitleDate() setNeedsStatusBarAppearanceUpdate() } - setTitleDate() - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidEndDragging(_ scrollView: UIScrollView,willDecelerate decelerate: Bool) { if !decelerate { - if !decelerate { - searchNewMedia() - } + searchNewMedia() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { searchNewMedia() } - - func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { } } // MARK: - diff --git a/iOSClient/Media/NCMediaPinchGesture.swift b/iOSClient/Media/NCMediaPinchGesture.swift index 3f0968322d..a242cca5ae 100644 --- a/iOSClient/Media/NCMediaPinchGesture.swift +++ b/iOSClient/Media/NCMediaPinchGesture.swift @@ -18,23 +18,29 @@ extension NCMedia { } if originalColumns != numberOfColumns { + collectionView.transform = .identity + currentScale = 1.0 - self.collectionView.transform = .identity - self.currentScale = 1.0 + buildMediaPhotoVideo(columnCount: numberOfColumns) - UIView.transition(with: self.collectionView, duration: 0.20, options: .transitionCrossDissolve) { - - self.collectionView.reloadData() + UIView.transition( + with: collectionView, + duration: 0.20, + options: .transitionCrossDissolve + ) { self.collectionView.collectionViewLayout.invalidateLayout() - + self.collectionView.reloadData() } completion: { _ in - - self.database.updatePhotoLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "") { layout in + self.database.updatePhotoLayoutForView( + account: self.session.account, + key: self.global.layoutViewMedia, + serverUrl: "" + ) { layout in layout.columnPhoto = self.numberOfColumns } - } } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { self.transitionColumns = false } @@ -43,14 +49,17 @@ extension NCMedia { switch gestureRecognizer.state { case .began: Task { - await self.networkRemoveAll() + await networkRemoveAll() } + lastScale = gestureRecognizer.scale lastNumberOfColumns = numberOfColumns + case .changed: guard !transitionColumns else { return } + let scale = gestureRecognizer.scale let scaleChange = scale / lastScale @@ -60,16 +69,21 @@ extension NCMedia { updateNumberOfColumns() if numberOfColumns > 1 && numberOfColumns < maxColumns { - collectionView.transform = CGAffineTransform(scaleX: currentScale, y: currentScale) + collectionView.transform = CGAffineTransform( + scaleX: currentScale, + y: currentScale + ) } lastScale = scale + case .ended: UIView.animate(withDuration: 0.30) { self.currentScale = 1.0 self.collectionView.transform = .identity self.setTitleDate() } + default: break } From 581b4b3e82d4407ed145dda16d55266d53196d12 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 16:45:42 +0200 Subject: [PATCH 10/22] feat: group media into monthly sections Add month and year section headers, update section-aware navigation and selection, and show media totals only in the final section footer. Signed-off-by: Marino Faggiana --- .../NCMedia+CollectionViewDataSource.swift | 104 ++++++++++++-- .../NCMedia+CollectionViewDelegate.swift | 2 +- iOSClient/Media/NCMedia+Command.swift | 39 ++---- iOSClient/Media/NCMedia+MediaLayout.swift | 18 +-- iOSClient/Media/NCMedia.swift | 2 +- iOSClient/Media/NCMediaDataSource.swift | 128 ++++++++++++------ 6 files changed, 207 insertions(+), 86 deletions(-) diff --git a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift index 210762c6ca..be14c05ad9 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift @@ -9,27 +9,107 @@ import RealmSwift extension NCMedia: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { if kind == mediaSectionHeader { - guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "sectionFirstHeaderEmptyData", for: indexPath) as? NCSectionFirstHeaderEmptyData else { return NCSectionFirstHeaderEmptyData() } - header.emptyImage.image = utility.loadImage(named: "photo", colors: [NCBrandColor.shared.getElement(account: session.account)]) - if self.searchMediaInProgress { - header.emptyTitle.text = NSLocalizedString("_search_in_progress_", comment: "") + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionFirstHeaderEmptyData", + for: indexPath + ) as? NCSectionFirstHeaderEmptyData else { + return NCSectionFirstHeaderEmptyData() + } + + if dataSource.isEmpty() { + header.emptyImage.isHidden = false + header.emptyDescription.isHidden = false + + header.emptyImage.image = utility.loadImage( + named: "photo", + colors: [ + NCBrandColor.shared.getElement( + account: session.account + ) + ] + ) + + if searchMediaInProgress { + header.emptyTitle.text = NSLocalizedString( + "_search_in_progress_", + comment: "" + ) + } else { + header.emptyTitle.text = NSLocalizedString( + "_tutorial_photo_view_", + comment: "" + ) + } + + header.emptyDescription.text = "" } else { - header.emptyTitle.text = NSLocalizedString("_tutorial_photo_view_", comment: "") + header.emptyImage.isHidden = true + header.emptyDescription.isHidden = true + + guard let yearMonth = dataSource.yearMonth( + for: indexPath.section + ) else { + header.emptyTitle.text = nil + return header + } + + var components = DateComponents() + components.year = yearMonth.year + components.month = yearMonth.month + components.day = 1 + + if let date = Calendar.current.date(from: components) { + header.emptyTitle.text = date.formatted( + .dateTime + .month(.wide) + .year() + ) + } else { + header.emptyTitle.text = + "\(yearMonth.month)/\(yearMonth.year)" + } } - header.emptyDescription.text = "" + return header - } else { - guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "sectionFooter", for: indexPath) as? NCSectionFooter else { return NCSectionFooter() } - let images = dataSource.compactMetadatas.filter({ $0.isImage }).count - let video = dataSource.compactMetadatas.count - images + } + + guard let footer = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionFooter", + for: indexPath + ) as? NCSectionFooter else { + return NCSectionFooter() + } - footer.setTitleLabel("\(images) " + NSLocalizedString("_images_", comment: "") + " • " + "\(video) " + NSLocalizedString("_video_", comment: "")) + guard indexPath.section == dataSource.numberOfSections - 1 else { + footer.setTitleLabel("") return footer } + + let images = dataSource.compactMetadatas.filter(\.isImage).count + let videos = dataSource.compactMetadatas.count - images + + footer.setTitleLabel( + "\(images) " + + NSLocalizedString("_images_", comment: "") + + " • " + + "\(videos) " + + NSLocalizedString("_video_", comment: "") + ) + + return footer } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - dataSource.compactMetadatas.count + guard !dataSource.isEmpty() else { + return 0 + } + return dataSource.numberOfItems(in: section) + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + dataSource.isEmpty() ? 1 : dataSource.numberOfSections } func collectionView(_ collectionView: UICollectionView, diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index cae23d2c9b..39f4564b92 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -67,7 +67,7 @@ extension NCMedia: UICollectionViewDelegate { ) } - let ocIds = dataSource.compactMetadatas.map(\.ocId) + let ocIds = dataSource.allOcIds if let viewController = await NCViewer().getViewerController( metadata: metadata, diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 42d3ca6712..cf0132bf6a 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -34,7 +34,10 @@ extension NCMedia { func setTitleDate() { let visibleIndexPaths = collectionView.indexPathsForVisibleItems.sorted { - $0.item < $1.item + if $0.section == $1.section { + return $0.item < $1.item + } + return $0.section < $1.section } guard let firstIndexPath = visibleIndexPaths.first, @@ -125,7 +128,15 @@ extension NCMedia { } private func currentVisibleYearMonth() -> NCYearMonth? { - guard let firstIndexPath = collectionView.indexPathsForVisibleItems.min(by: { $0.item < $1.item }), + let firstIndexPath = collectionView.indexPathsForVisibleItems.min { + if $0.section == $1.section { + return $0.item < $1.item + } + + return $0.section < $1.section + } + + guard let firstIndexPath, let metadata = dataSource.getCompactMetadata(indexPath: firstIndexPath) else { return nil } @@ -154,19 +165,6 @@ extension NCMedia { collectionView.layoutIfNeeded() setTitleDate() } - - private func formattedTitle(year: Int, month: Int) -> String { - var components = DateComponents() - components.year = year - components.month = month - components.day = 1 - - guard let date = Calendar.current.date(from: components) else { - return "" - } - - return date.formatted(.dateTime .month(.wide) .year()) - } } extension NCMedia: NCMediaSelectTabBarDelegate { @@ -249,15 +247,8 @@ extension NCMedia: NCMediaSelectTabBarDelegate { await self.database.deleteMetadataAsync(id: ocId) await MainActor.run { - if let indexPath = self.dataSource.indexPath(forOcId: ocId) { - self.collectionView.performBatchUpdates { - self.dataSource.removeCompactMetadata([ocId]) - self.collectionView.deleteItems(at: [indexPath]) - } - } else { - self.dataSource.removeCompactMetadata([ocId]) - self.collectionViewReloadData() - } + self.dataSource.removeCompactMetadata([ocId]) + self.collectionViewReloadData() } } } diff --git a/iOSClient/Media/NCMedia+MediaLayout.swift b/iOSClient/Media/NCMedia+MediaLayout.swift index 2b5ddd7f4d..40afcd9068 100644 --- a/iOSClient/Media/NCMedia+MediaLayout.swift +++ b/iOSClient/Media/NCMedia+MediaLayout.swift @@ -24,19 +24,21 @@ extension NCMedia: NCMediaLayoutDelegate { } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForHeaderInSection section: Int) -> Float { - var height: Double = 0 - if dataSource.compactMetadatas.count == 0 { - height = utility.getHeightHeaderEmptyData(view: view, portraitOffset: 0, landscapeOffset: -20) - } - return Float(height) + if dataSource.isEmpty() { + return section == 0 + ? Float(utility.getHeightHeaderEmptyData(view: view, portraitOffset: 0, landscapeOffset: -20)) + : .zero + } + + return 40.0 } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForFooterInSection section: Int) -> Float { - if dataSource.compactMetadatas.count == 0 { + guard !dataSource.isEmpty() else { return .zero - } else { - return 100.0 } + + return section == dataSource.numberOfSections - 1 ? 100.0 : .zero } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, insetForSection section: Int) -> UIEdgeInsets { diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index ef107f5384..64a2cd2478 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -277,7 +277,7 @@ extension NCMedia: UIScrollViewDelegate { } } - func scrollViewDidEndDragging(_ scrollView: UIScrollView,willDecelerate decelerate: Bool) { + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { searchNewMedia() } diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 7aff6c0b73..8c99f79c8e 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -522,18 +522,18 @@ public class NCMediaDataSource: NSObject { } } + public struct NCMediaSection { + let yearMonth: NCYearMonth + var compactMetadatas: [NCCompactMetadata] + } + private let utilityFileSystem = NCUtilityFileSystem() private let global = NCGlobal.shared private(set) var compactMetadatas: [NCCompactMetadata] = [] - private(set) var firstIndexByYearMonth: [NCYearMonth: Int] = [:] + private(set) var sections: [NCMediaSection] = [] var availableYearMonths: [NCYearMonth] { - firstIndexByYearMonth.keys.sorted { - if $0.year == $1.year { - return $0.month > $1.month - } - return $0.year > $1.year - } + sections.map(\.yearMonth) } override init() { super.init() } @@ -545,9 +545,7 @@ public class NCMediaDataSource: NSObject { getCompactMetadataFromMetadata($0) } - self.firstIndexByYearMonth = makeFirstIndexByYearMonth( - from: compactMetadatas - ) + self.sections = makeSections(from: compactMetadatas) } private func getCompactMetadataFromMetadata(_ metadata: tableMetadata) -> NCCompactMetadata { @@ -564,39 +562,73 @@ public class NCMediaDataSource: NSObject { // MARK: - func clearCompactMetadatas() { - self.compactMetadatas.removeAll() - self.firstIndexByYearMonth.removeAll() + compactMetadatas.removeAll() + sections.removeAll() } func isEmpty() -> Bool { return self.compactMetadatas.isEmpty } - func indexPath(forOcId ocId: String) -> IndexPath? { - guard let index = self.compactMetadatas.firstIndex(where: { $0.ocId == ocId }) else { + var numberOfSections: Int { + sections.count + } + + func numberOfItems(in section: Int) -> Int { + guard sections.indices.contains(section) else { + return 0 + } + + return sections[section].compactMetadatas.count + } + + func yearMonth(for section: Int) -> NCYearMonth? { + guard sections.indices.contains(section) else { return nil } - return IndexPath(item: index, section: 0) + return sections[section].yearMonth } - func getCompactMetadata(indexPath: IndexPath) -> NCCompactMetadata? { - if indexPath.row < self.compactMetadatas.count { - return self.compactMetadatas[indexPath.row] + var allOcIds: [String] { + compactMetadatas.map(\.ocId) + } + + func indexPath(forOcId ocId: String) -> IndexPath? { + for (sectionIndex, section) in sections.enumerated() { + guard let itemIndex = section.compactMetadatas.firstIndex(where: { + $0.ocId == ocId + }) else { + continue + } + + return IndexPath( + item: itemIndex, + section: sectionIndex + ) } return nil } - func getCompactMetadatas(indexPaths: [IndexPath]) -> [NCCompactMetadata] { - var metadatas: [NCCompactMetadata] = [] - for indexPath in indexPaths { - if indexPath.row < self.compactMetadatas.count { - metadatas.append(self.compactMetadatas[indexPath.row]) - } + func getCompactMetadata(indexPath: IndexPath) -> NCCompactMetadata? { + guard sections.indices.contains(indexPath.section) else { + return nil + } + + let metadatas = sections[indexPath.section].compactMetadatas + + guard metadatas.indices.contains(indexPath.item) else { + return nil } - return metadatas + return metadatas[indexPath.item] + } + + func getCompactMetadatas(indexPaths: [IndexPath]) -> [NCCompactMetadata] { + indexPaths.compactMap { + getCompactMetadata(indexPath: $0) + } } func removeCompactMetadata(_ ocIds: [String]) { @@ -606,40 +638,56 @@ public class NCMediaDataSource: NSObject { ocIds.contains(metadata.ocId) } - firstIndexByYearMonth = makeFirstIndexByYearMonth( - from: compactMetadatas - ) + sections = makeSections(from: compactMetadatas) } func firstIndexPath(year: Int, month: Int) -> IndexPath? { - guard let index = firstIndex( + let yearMonth = NCYearMonth( year: year, month: month - ) else { + ) + + guard let sectionIndex = sections.firstIndex(where: { + $0.yearMonth == yearMonth + }) else { return nil } - return IndexPath(item: index, section: 0) - } + guard !sections[sectionIndex].compactMetadatas.isEmpty else { + return nil + } - func firstIndex(year: Int, month: Int) -> Int? { - firstIndexByYearMonth[NCYearMonth(year: year, month: month)] + return IndexPath( + item: 0, + section: sectionIndex + ) } - private func makeFirstIndexByYearMonth(from metadatas: [NCCompactMetadata]) -> [NCYearMonth: Int] { - var result: [NCYearMonth: Int] = [:] + private func makeSections( + from metadatas: [NCCompactMetadata] + ) -> [NCMediaSection] { + var sections: [NCMediaSection] = [] - for (index, metadata) in metadatas.enumerated() { + for metadata in metadatas { guard let yearMonth = NCYearMonth(date: metadata.date) else { continue } - if result[yearMonth] == nil { - result[yearMonth] = index + if sections.last?.yearMonth == yearMonth { + sections[sections.count - 1] + .compactMetadatas + .append(metadata) + } else { + sections.append( + NCMediaSection( + yearMonth: yearMonth, + compactMetadatas: [metadata] + ) + ) } } - return result + return sections } } From 4189b409869ff06daed042e1539f7eed225a6a00 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 17:09:51 +0200 Subject: [PATCH 11/22] feat: add dedicated media section headers Display month and year in a reusable media section header while preserving the existing empty-state header. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 16 +++++ .../NCMedia+CollectionViewDataSource.swift | 68 ++++++++++--------- iOSClient/Media/NCMedia.swift | 1 + .../Section header/NCMediaSectionHeader.swift | 16 +++++ .../Section header/NCMediaSectionHeader.xib | 33 +++++++++ 5 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 iOSClient/Media/Section header/NCMediaSectionHeader.swift create mode 100644 iOSClient/Media/Section header/NCMediaSectionHeader.xib diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 21d13b9b3e..4585cb3982 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -584,6 +584,8 @@ F77444F622281649000D5EB0 /* NCMediaCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F77444F422281649000D5EB0 /* NCMediaCell.xib */; }; F77444F8222816D5000D5EB0 /* NCPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77444F7222816D5000D5EB0 /* NCPickerViewController.swift */; }; F778231E2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F778231D2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift */; }; + F77A6B30301112C5007103E6 /* NCMediaSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */; }; + F77A6B31301112C5007103E6 /* NCMediaSectionHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */; }; F77B0F631D118A16002130FE /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F7E70DE91A24DE4100E1B66A /* Localizable.strings */; }; F77B0F7D1D118A16002130FE /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F7F67BB81A24D27800EE80DA /* Images.xcassets */; }; F77B0F893008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77B0F883008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift */; }; @@ -1623,6 +1625,8 @@ F77444F422281649000D5EB0 /* NCMediaCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCMediaCell.xib; sourceTree = ""; }; F77444F7222816D5000D5EB0 /* NCPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCPickerViewController.swift; sourceTree = ""; }; F778231D2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+MediaLayout.swift"; sourceTree = ""; }; + F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaSectionHeader.swift; sourceTree = ""; }; + F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCMediaSectionHeader.xib; sourceTree = ""; }; F77B0F883008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCBlurEffectsTestView.swift; sourceTree = ""; }; F77BB745289984CA0090FC19 /* UIViewController+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extension.swift"; sourceTree = ""; }; F77BB747289985270090FC19 /* UITabBarController+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITabBarController+Extension.swift"; sourceTree = ""; }; @@ -2901,6 +2905,15 @@ path = Files; sourceTree = ""; }; + F77A6B2F301112C5007103E6 /* Section header */ = { + isa = PBXGroup; + children = ( + F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */, + F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */, + ); + path = "Section header"; + sourceTree = ""; + }; F77B0F8A3008ACAD00EE4AE1 /* Test */ = { isa = PBXGroup; children = ( @@ -3441,6 +3454,7 @@ isa = PBXGroup; children = ( F720B5B72507B9A5008C94E5 /* Cell */, + F77A6B2F301112C5007103E6 /* Section header */, F7501C302212E57400FB1415 /* NCMedia.storyboard */, F7501C312212E57400FB1415 /* NCMedia.swift */, F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */, @@ -4276,6 +4290,7 @@ F768822D2C0DD1E7001CF441 /* Acknowledgements.rtf in Resources */, F76D3CF32428B94E005DFA87 /* NCViewerPDFSearchCell.xib in Resources */, F717402D24F699A5000C87D5 /* NCFavorite.storyboard in Resources */, + F77A6B31301112C5007103E6 /* NCMediaSectionHeader.xib in Resources */, F723B3DD22FC6D1D00301EFE /* NCShareCommentsCell.xib in Resources */, F78ACD4B21903F850088454D /* NCTrashListCell.xib in Resources */, AF93471927E2361E002537EE /* NCShareAdvancePermissionFooter.xib in Resources */, @@ -4907,6 +4922,7 @@ F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */, F724377B2C10B83E00C7C68D /* NCSharePermissions.swift in Sources */, F317C82E2E844C5300761AEA /* ClientIntegrationUIViewer.swift in Sources */, + F77A6B30301112C5007103E6 /* NCMediaSectionHeader.swift in Sources */, F794E13D2BBBFF2E003693D7 /* NCMainTabBarController.swift in Sources */, F7CBC1252BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */, F7D4BF3D2CA2E8D800A5E746 /* TOPasscodeKeypadView.m in Sources */, diff --git a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift index be14c05ad9..cb21e6bae8 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift @@ -9,15 +9,15 @@ import RealmSwift extension NCMedia: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { if kind == mediaSectionHeader { - guard let header = collectionView.dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: "sectionFirstHeaderEmptyData", - for: indexPath - ) as? NCSectionFirstHeaderEmptyData else { - return NCSectionFirstHeaderEmptyData() - } - if dataSource.isEmpty() { + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionFirstHeaderEmptyData", + for: indexPath + ) as? NCSectionFirstHeaderEmptyData else { + return NCSectionFirstHeaderEmptyData() + } + header.emptyImage.isHidden = false header.emptyDescription.isHidden = false @@ -43,32 +43,36 @@ extension NCMedia: UICollectionViewDataSource { } header.emptyDescription.text = "" - } else { - header.emptyImage.isHidden = true - header.emptyDescription.isHidden = true - - guard let yearMonth = dataSource.yearMonth( - for: indexPath.section - ) else { - header.emptyTitle.text = nil - return header - } - var components = DateComponents() - components.year = yearMonth.year - components.month = yearMonth.month - components.day = 1 + return header + } - if let date = Calendar.current.date(from: components) { - header.emptyTitle.text = date.formatted( - .dateTime - .month(.wide) - .year() - ) - } else { - header.emptyTitle.text = - "\(yearMonth.month)/\(yearMonth.year)" - } + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionHeader", + for: indexPath + ) as? NCMediaSectionHeader else { + return NCMediaSectionHeader() + } + + guard let yearMonth = dataSource.yearMonth(for: indexPath.section) else { + header.titleLabel.text = nil + return header + } + + var components = DateComponents() + components.year = yearMonth.year + components.month = yearMonth.month + components.day = 1 + + if let date = Calendar.current.date(from: components) { + header.titleLabel.text = date.formatted( + .dateTime + .month(.wide) + .year() + ) + } else { + header.titleLabel.text = "\(yearMonth.month)/\(yearMonth.year)" } return header diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 64a2cd2478..7f85f641f1 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -101,6 +101,7 @@ class NCMedia: UIViewController { view.backgroundColor = .systemBackground collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") + collectionView.register(UINib(nibName: "NCMediaSectionHeader", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionHeader") collectionView.register(UINib(nibName: "NCSectionFooter", bundle: nil), forSupplementaryViewOfKind: mediaSectionFooter, withReuseIdentifier: "sectionFooter") collectionView.register(UINib(nibName: "NCMediaCell", bundle: nil), forCellWithReuseIdentifier: "mediaCell") collectionView.alwaysBounceVertical = true diff --git a/iOSClient/Media/Section header/NCMediaSectionHeader.swift b/iOSClient/Media/Section header/NCMediaSectionHeader.swift new file mode 100644 index 0000000000..c6cf1cc90b --- /dev/null +++ b/iOSClient/Media/Section header/NCMediaSectionHeader.swift @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import UIKit + +class NCMediaSectionHeader: UICollectionReusableView { + @IBOutlet weak var titleLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + self.backgroundColor = UIColor.clear + self.titleLabel.text = "" + } +} diff --git a/iOSClient/Media/Section header/NCMediaSectionHeader.xib b/iOSClient/Media/Section header/NCMediaSectionHeader.xib new file mode 100644 index 0000000000..9eb32cb0a7 --- /dev/null +++ b/iOSClient/Media/Section header/NCMediaSectionHeader.xib @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 64deec301321105cf4a105d2afe16d397f92f091 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 22 Jul 2026 17:24:45 +0200 Subject: [PATCH 12/22] fix: align media navigation with section headers Increase section header spacing and add leading padding for clearer media grouping. Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+Command.swift | 34 ++++++++++++++----- iOSClient/Media/NCMedia+MediaLayout.swift | 2 +- .../Section header/NCMediaSectionHeader.xib | 4 +-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index cf0132bf6a..4048f2f7ea 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -151,16 +151,34 @@ extension NCMedia { collectionView.layoutIfNeeded() - guard let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else { - return - } + let sectionIndexPath = IndexPath(item: 0, section: indexPath.section) + + if let attributes = collectionView.collectionViewLayout.layoutAttributesForSupplementaryView( + ofKind: mediaSectionHeader, + at: sectionIndexPath + ) { + let spacingBelowNavigationBar: CGFloat = 16 + + let targetOffsetY = max( + -collectionView.adjustedContentInset.top, + attributes.frame.minY + - collectionView.adjustedContentInset.top + - spacingBelowNavigationBar + ) - let targetOffsetY = attributes.frame.minY - collectionView.adjustedContentInset.top + collectionView.setContentOffset( + CGPoint(x: collectionView.contentOffset.x, y: targetOffsetY), + animated: false + ) + } else if let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) { + // Fallback + let targetOffsetY = attributes.frame.minY - collectionView.adjustedContentInset.top - collectionView.setContentOffset( - CGPoint(x: collectionView.contentOffset.x, y: targetOffsetY), - animated: false - ) + collectionView.setContentOffset( + CGPoint(x: collectionView.contentOffset.x, y: targetOffsetY), + animated: false + ) + } collectionView.layoutIfNeeded() setTitleDate() diff --git a/iOSClient/Media/NCMedia+MediaLayout.swift b/iOSClient/Media/NCMedia+MediaLayout.swift index 40afcd9068..f574c9409b 100644 --- a/iOSClient/Media/NCMedia+MediaLayout.swift +++ b/iOSClient/Media/NCMedia+MediaLayout.swift @@ -30,7 +30,7 @@ extension NCMedia: NCMediaLayoutDelegate { : .zero } - return 40.0 + return 80.0 } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForFooterInSection section: Int) -> Float { diff --git a/iOSClient/Media/Section header/NCMediaSectionHeader.xib b/iOSClient/Media/Section header/NCMediaSectionHeader.xib index 9eb32cb0a7..b2f7f82a71 100644 --- a/iOSClient/Media/Section header/NCMediaSectionHeader.xib +++ b/iOSClient/Media/Section header/NCMediaSectionHeader.xib @@ -13,7 +13,7 @@