Hi TCA community,
when working on a project, I ran into an error with fullscreen covers and alerts. TCA attempts to present an alert before a fullscreen cover view has been dismissed, which results in a UIKit error, and the alert not being shown.
Code to reproduce
import ComposableArchitecture
import Combine
import PhotosUI
import SwiftUI
struct PhotoPickerAlertState: Equatable {
var alert: AlertState<PhotoPickerAlertAction>?
var isPhotoPickerPresented = false
}
enum PhotoPickerAlertAction: Equatable {
case alertButtonTapped
case alertDismissed
/// User requested to show photo.
case showPhotoPicker
/// User picked item in photo picker.
case photoPickerPickedPhoto(itemProvider: NSItemProvider)
/// User dismissed photo picker.
case photoPickerCanceled
/// Photo picker fullscreen cover was dismissed by UI, regardless of result.
case photoPickerDismissed
case photoProcessorCompleted(Result<Bool, PhotoProcessorError>)
}
struct PhotoPickerAlertEnvironment {
let photoProcessor = PhotoProcessor()
}
let photoPickerAlertReducer = Reducer<
PhotoPickerAlertState, PhotoPickerAlertAction,
PhotoPickerAlertEnvironment
> { state, action, environment in
switch action {
case .alertButtonTapped:
state.alert = .init(
title: .init("Hi. I’m an alert"),
dismissButton: .cancel(.init("OK"))
)
return .none
case .alertDismissed:
state.alert = nil
return .none
case .showPhotoPicker:
state.isPhotoPickerPresented = true
return .none
case .photoPickerPickedPhoto(let itemProvider):
state.isPhotoPickerPresented = false
return environment.photoProcessor.doSomethingWithPickedPhoto(item: itemProvider)
.receive(on: DispatchQueue.main)
// Uncomment the following line to see correct behavior
// .delay(for: .seconds(0.4), scheduler: DispatchQueue.main)
.catchToEffect(PhotoPickerAlertAction.photoProcessorCompleted)
case .photoPickerCanceled:
state.isPhotoPickerPresented = false
return .none
case .photoPickerDismissed:
return .none
case let .photoProcessorCompleted(.success(boolValue)):
state.alert = .init(
title: .init("You picked a cool photo! Result: \(String(describing: boolValue))"),
dismissButton: .cancel(.init("OK"))
)
return .none
case let .photoProcessorCompleted(.failure(error)):
state.alert = .init(
title: .init("Something failed when picking a photo: \(String(describing: error))"),
dismissButton: .cancel(.init("OK"))
)
return .none
}
}
struct PhotoPickerAlertView: View {
let store: Store<PhotoPickerAlertState, PhotoPickerAlertAction>
var body: some View {
WithViewStore(self.store) { viewStore in
Form {
Section {
Button("Pick a photo") { viewStore.send(.showPhotoPicker) }
Button("Show alert") { viewStore.send(.alertButtonTapped) }
}
}
.navigationBarTitle("Photo picker alert")
.fullScreenCover(
isPresented: viewStore.binding(
get: \.isPhotoPickerPresented,
send: PhotoPickerAlertAction.photoPickerDismissed
)
) {
PhotoPicker(store: store)
}
.alert(
self.store.scope(state: \.alert),
dismiss: .alertDismissed
)
}
}
}
struct PhotoPickerAlert_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
PhotoPickerAlertView(
store: .init(
initialState: .init(),
reducer: photoPickerAlertReducer,
environment: .init()
)
)
}
}
}
enum PhotoProcessorError: Error {
case someError
}
struct PhotoProcessor {
func doSomethingWithPickedPhoto(item: NSItemProvider) -> AnyPublisher<Bool, PhotoProcessorError> {
Just(true).setFailureType(to: PhotoProcessorError.self).eraseToAnyPublisher()
}
}
struct PhotoPicker: UIViewControllerRepresentable {
let store: Store<PhotoPickerAlertState, PhotoPickerAlertAction>
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration()
configuration.selectionLimit = 1
let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = context.coordinator
return pickerViewController
}
func updateUIViewController(_: PHPickerViewController, context _: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
private let parent: PhotoPicker
init(_ parent: PhotoPicker) {
self.parent = parent
}
func picker(_: PHPickerViewController, didFinishPicking result: [PHPickerResult]) {
let viewStore = ViewStore(parent.store)
if let itemProvider = result.first?.itemProvider {
viewStore.send(.photoPickerPickedPhoto(itemProvider: itemProvider))
} else {
viewStore.send(.photoPickerCanceled)
}
}
}
}
Video of desired behavior: https://we.tl/t-pE10G9sxjQ
When you run the code, you do not see the alert. Instead, in the console, you see a UIKit error that is familiar to many of us:
2022-06-23 14:33:21.928104+0300 SwiftUICaseStudies[4936:1813721] [Presentation] Attempt to present <SwiftUI.PlatformAlertController: 0x7f801d916a00> on <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x7f801e006ab0> (from <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_24NavigationColumnModifier__: 0x7f802e909160>) which is already presenting <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x7f802d733ef0>.
What this means: UIKit attempted to present a modal viewcontroller (alert) while another modal viewcontroller (photo picker) was still being displayed, when there is no child-parent relationship between them. UIKit does not like this, and does not display the alert.
A somewhat hacky fix is on line 63 of the code. By inserting a delay before returning the result of the photo processor (which is invoked with the result of the photo picker), it works as expected. But this feels incorrect.
It feels like TCA should help me here and be able to sequence things better, so that I can just request to display the alert, without worrying what else is showing on the screen at that time. Perhaps there is a way to already do this that I have missed?