Files
element-x-ios/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift
T
Ghazi Tozri fadd19d847 Fix photo picker selection tint on iOS 26
On iOS 26 the picker's remote process samples the window's tint color when it connects, ignoring the view-level override, which left the selection badges white on grey in dark mode. Temporarily override the window tint while the picker is presented and restore it when picking finishes or the picker is dismantled.
2026-09-02 15:28:43 +02:00

185 lines
7.1 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import PhotosUI
import SwiftUI
enum PhotoLibraryPickerAction {
case selectedMediaAtURLs([URL])
case cancel
case error(PhotoLibraryPickerError)
}
enum PhotoLibraryPickerError: Error {
case failedLoadingFileRepresentation(Error?)
case failedCopyingFile
}
struct PhotoLibraryPicker: UIViewControllerRepresentable {
private let selectionType: MediaPickerScreenSelectionType
private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (PhotoLibraryPickerAction) -> Void
init(selectionType: MediaPickerScreenSelectionType,
userIndicatorController: UserIndicatorControllerProtocol,
callback: @escaping (PhotoLibraryPickerAction) -> Void) {
self.selectionType = selectionType
self.userIndicatorController = userIndicatorController
self.callback = callback
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selection = .ordered
configuration.selectionLimit = switch selectionType {
case .single:
1
case .multiple(let limit):
limit
}
let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = context.coordinator
return pickerViewController
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// Override the app wide tint color (currently set to `.compound.texActionPrimary`)
// as it's not legible enough in dark mode
uiViewController.view.tintColor = .compound.textActionAccent
// On iOS 26 the picker reads the window's tint instead so we need
// to make this workaround to override the window tint
DispatchQueue.main.async { [coordinator = context.coordinator] in
coordinator.overrideWindowTint(of: uiViewController)
}
}
static func dismantleUIViewController(_ uiViewController: PHPickerViewController, coordinator: Coordinator) {
coordinator.restoreWindowTint()
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
private var photoLibraryPicker: PhotoLibraryPicker
private weak var overriddenWindow: UIWindow?
private var originalWindowTint: UIColor?
init(_ photoLibraryPicker: PhotoLibraryPicker) {
self.photoLibraryPicker = photoLibraryPicker
}
func overrideWindowTint(of pickerViewController: PHPickerViewController) {
guard overriddenWindow == nil, let window = pickerViewController.view.window else { return }
overriddenWindow = window
originalWindowTint = window.tintColor
window.tintColor = .compound.textActionAccent
}
func restoreWindowTint() {
overriddenWindow?.tintColor = originalWindowTint
overriddenWindow = nil
}
// MARK: PHPickerViewControllerDelegate
private static let loadingIndicatorIdentifier = "\(PhotoLibraryPicker.self)-Loading"
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
restoreWindowTint()
guard !results.isEmpty else {
photoLibraryPicker.callback(.cancel)
return
}
picker.delegate = nil
photoLibraryPicker.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading))
defer {
photoLibraryPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
Task {
let selectedURLs = await withTaskGroup { taskGroup in
for (index, result) in results.enumerated() {
taskGroup.addTask {
let url = await self.processResult(result)
return (index, url)
}
}
var selectedURLs = [URL?](repeating: nil, count: results.count)
for await (index, url) in taskGroup {
if let url {
selectedURLs[index] = url
}
}
return selectedURLs.compactMap { $0 }
}
guard !selectedURLs.isEmpty else {
// Every selected item failed to load; each failure was already surfaced via .error.
return
}
photoLibraryPicker.callback(.selectedMediaAtURLs(selectedURLs))
}
}
// MARK: - Private
func processResult(_ result: PHPickerResult) async -> URL? {
let provider = result.itemProvider
guard let contentType = provider.preferredContentType else {
Task { @MainActor in
photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(nil)))
}
return nil
}
return await withCheckedContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: contentType.type.identifier) { [weak self] url, error in
guard let url else {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(error)))
}
continuation.resume(returning: nil)
return
}
do {
_ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
url.stopAccessingSecurityScopedResource()
Task { @MainActor in
continuation.resume(returning: newURL)
}
} catch {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedCopyingFile))
continuation.resume(returning: nil)
}
}
}
}
}
}
}