Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Presenting alert immediately after fullscreen cover results in UIKit error #1277

Closed
2 tasks done
jaanus opened this issue Aug 19, 2022 · 2 comments
Closed
2 tasks done
Labels
apple bug Something isn't working due to a bug on Apple's platforms.

Comments

@jaanus
Copy link

jaanus commented Aug 19, 2022

Description

Not sure if this is a TCA bug, or something on the application side. However, I am still posting it because I couldn’t find any guidance about this case.

In a nutshell: when there is a fullscreen cover, and alert shown immediately after dismissing the fullscreen cover, TCA changes the state such that SwiftUI attempts to present the alert before fullscreen cover is fully dismissed. This results in a UIKit error, and the alert not being shown.

Checklist

  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue.

Expected behavior

Instead of alert, I get the following UIKit error in console.

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>.

Actual behavior

The fullscreen cover dismissal and alert showing should be sequenced in the correct fashion and happen one after another without me having to put in delays or otherwise hack the system.

Steps to reproduce

Example project to reproduce the correct/incorrect behavior:

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 example of desired behavior:

Simulator.Screen.Recording.-.iPhone.13.Pro.-.2022-08-19.15.16.19.mp4

The Composable Architecture version information

cfcf8b4 (June 20 2022)

Destination operating system

iOS 15.5

Xcode version information

13.4 (13F17a)

Swift Compiler version information

swift-driver version: 1.45.2 Apple Swift version 5.6.1 (swiftlang-5.6.0.323.66 clang-1316.0.20.12)
Target: x86_64-apple-macosx12.0
@jaanus jaanus added the bug Something isn't working due to a bug in the library. label Aug 19, 2022
@jaanussiim
Copy link
Contributor

The issue is, that you need to wait for .photoPickerDismissed action (system notifying that full screen cover was dismissed) before you can show an alert.

One option is to add state variable

var maybeAlert: AlertState<PhotoPickerAlertAction>?

When receiving .photoProcessorCompleted, assign to that and on . photoPickerDismissed forward it

case .photoPickerDismissed:
  state.alert = state.maybeAlert
  return .none

@mbrandonw
Copy link
Member

Just to expand on what @jaanussiim said, I think this should be considered a SwiftUI bug and not something the library should be trying to fix. You can reproduce the problem in this very simple vanilla SwiftUI app:

struct ContentView: View {
  @State var isFullscreenCoverPresented = false
  @State var isAlertPresented = false

  var body: some View {
    Button("Present") {
      self.isFullscreenCoverPresented = true
    }
    .fullScreenCover(isPresented: self.$isFullscreenCoverPresented) {
      Button("Close") {
        self.isFullscreenCoverPresented = false
        self.isAlertPresented = true
      }
    }
    .alert("Hi", isPresented: self.$isAlertPresented) {
      Text("Hello")
    }
  }
}

Since this is not an issue with the library I am going to move it to a discussion.

@pointfreeco pointfreeco locked and limited conversation to collaborators Aug 19, 2022
@mbrandonw mbrandonw converted this issue into discussion #1278 Aug 19, 2022
@stephencelis stephencelis added apple bug Something isn't working due to a bug on Apple's platforms. and removed bug Something isn't working due to a bug in the library. labels Aug 19, 2022

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
apple bug Something isn't working due to a bug on Apple's platforms.
Projects
None yet
Development

No branches or pull requests

4 participants