Dismiss animation leaves a ghost view #2684
-
I'm working on a "toast" feature that presents a brief message at the top of the screen and then dismisses it after a delay. It works, but there's a strange behavior when you have one toast view followed by another. As the new toast is fading out, the old view briefly fades in and back out again. If you remove the animation when dismissing the toast view, the issue is resolved (but, obviously there's no animation which is not good). I made a minimal project to demonstrate the issue, but here's the code. First there's a dependency client that transmits the message from the child to the root view (technique lifted from this conversation). --Edit: I should've mentioned that I'm using the @DependencyClient
struct ToastClient: Sendable {
var observe: @Sendable () -> AsyncStream<String> = { AsyncStream([String]().async) }
var present: @Sendable (_ title: String) async -> Void
}
extension ToastClient: TestDependencyKey {
static let testValue = ToastClient()
}
extension ToastClient: DependencyKey {
static let liveValue = {
let (stream, continuation) = AsyncStream.makeStream(of: String.self)
return ToastClient(
observe: { stream },
present: { continuation.yield($0) }
)
}()
}
extension DependencyValues {
var toast: ToastClient {
get { self[ToastClient.self] }
set { self[ToastClient.self] = newValue }
}
} The root view monitors for values from the client's @Reducer
struct Root {
@ObservableState
struct State {
@Presents var toast: Toast.State?
}
enum Action {
case showToastButtonTapped
case task
case toast(PresentationAction<Toast.Action>)
case toastObserved(String)
}
@Dependency(\.toast) var toast
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .showToastButtonTapped:
return .run { _ in
let id = Int.random(in: 0..<100)
await toast.present(title: "\(id)")
}
case .task:
return .run { send in
for await title in toast.observe() {
await send(.toastObserved(title), animation: .easeInOut)
}
}
case .toastObserved(let newTitle):
state.toast = Toast.State(title: newTitle)
return .none
case .toast:
return .none
}
}
.ifLet(\.$toast, action: \.toast) {
Toast()
}
}
} The root view just has a button to send a new toast message and a task that starts the observation. struct RootView: View {
@Bindable var store: StoreOf<Root>
var body: some View {
ZStack {
Color.yellow
.ignoresSafeArea()
Button("Toast me") {
store.send(.showToastButtonTapped, animation: .easeInOut)
}
}
.toast(
store: $store.scope(
state: \.toast,
action: \.toast
)
)
.task {
await store.send(.task).finish()
}
}
} The @Reducer
struct Toast {
@ObservableState
struct State {
var title: String
}
enum Action: Sendable {
case task
case timerFinished
}
@Dependency(\.continuousClock) var clock
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .task:
return .run { [clock] send in
try await clock.sleep(for: .seconds(1))
await send(.timerFinished)
}
case .timerFinished:
return .run { [dismiss] _ in
await dismiss(animation: .easeInOut)
}
}
}
}
} The toast view is an overlay over the root view so it can present the toast anywhere on the screen. struct ToastView: View {
let store: StoreOf<Toast>
var body: some View {
Text(store.title)
.font(.caption)
.task {
store.send(.task)
}
}
}
struct ToastModifier: ViewModifier {
@Binding var store: StoreOf<Toast>?
func body(content: Content) -> some View {
content
.overlay(alignment: .top) {
if let store {
ToastView(store: store)
}
}
}
}
extension View {
func toast(
store: Binding<Store<Toast.State, Toast.Action>?>
) -> some View {
self
.modifier(ToastModifier(store: store))
}
} To see the issue, click the button, wait for the toast to be dismissed, then click the button again. As the second toast view fades out, you'll see the previous one fade in briefly. Remove the animation from the Another way to remove the issue is to just have the button generate the Any ideas on how to find out why that old view reappears? |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 1 reply
-
@seanmrich I think if you update to the latest commit on the branch you should see the issue fixed. Previously, the optional |
Beta Was this translation helpful? Give feedback.
@seanmrich I think if you update to the latest commit on the branch you should see the issue fixed. Previously, the optional
scope
was only caching the initial unwrapped value, but since then it maintains the latest value over time.