Trying to create a custom alert using prerelease/1.0
#2027
-
Hi, I'm following the last series of TCA videos related to navigation and I think is an amazing opportunity for me to finally wrap my head around the TCA paradigm. I'm trying to create a custom alert (a small bar to display at the top of the current view) using what I saw on the last video, but so far I couldn't accomplished what I want. My idea is to create a custom view with a custom presentation to use it within a ViewModifier and a Right now the project is compiling (with a commented failing line) but no view is displayed at all (even in the preview). I can't figure out what I'm doing wrong. If someone can point me on the right direction, I'll appreciate it. |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 3 replies
-
Updated project's code trying something else, but still getting this error
|
Beta Was this translation helpful? Give feedback.
-
Sorry, I'll show the piece of code giving me that error to provide some context import ComposableArchitecture
import SwiftUI
struct Main: Reducer {
struct State: Equatable {
var destination: Destination.State?
}
enum Action: Equatable {
case didTapButton
case destination(PresentationAction<Destination.Action>)
}
struct Destination: Reducer {
enum State: Equatable, Identifiable {
case whisper(Whisper.State)
var id: AnyHashable {
switch self {
case let .whisper(state):
return state.id
}
}
}
enum Action: Equatable {
case whisper(Whisper.Action)
}
var body: some ReducerOf<Self> {
Scope(state: /State.whisper, action: /Action.whisper) {
Whisper()
}
}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .didTapButton:
state.destination = .whisper(.init(id: UUID(), message: "This is a test", type: .success))
return .none
case .destination:
return .none
}
}
.ifLet(\.destination, action: /Action.destination) { // Getting "Key path value type 'Main.Destination.State?' cannot be converted to contextual type 'PresentationState<Main.Destination.State>'"
Destination()
}
}
} |
Beta Was this translation helpful? Give feedback.
-
Still trying to wrap my head arround creating custom alert presentations using TCA (I've updated my test project, but still no luck) and that made me think: "Is this the best approach? Can be achived what I'm trying to do?" SwiftUI is very limited in terms of handling custom presentations, forcing to use ViewModifiers in case we want to display a custom View in a particular way, beyond alerts, confirmation dialogs, sheets or whatever. That being said, I think that my idea of using TCA to handle a ViewModifier visibility the same way as an alert destination, can't be achieved right now? Should I use a more simpler approach (using an observed boolean to toggle visibility) ? Anyway, this is part of my current code, with the current pailing part: MainReducer struct Main: Reducer {
struct State: Equatable {
@PresentationState var destination: Destination.State?
}
enum Action: Equatable {
case didTapButton
case destination(PresentationAction<Destination.Action>)
}
struct Destination: Reducer {
enum State: Equatable, Identifiable {
case whisper(Whisper.State)
var id: AnyHashable {
switch self {
case let .whisper(state):
return state.id
}
}
}
enum Action: Equatable {
case whisper(Whisper.Action)
}
var body: some ReducerOf<Self> {
Scope(state: /State.whisper, action: /Action.whisper) {
Whisper()
}
}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .didTapButton:
state.destination = .whisper(.init(id: UUID(), message: "This is a test", type: .success))
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: /Action.destination) {
Destination()
}
}
} MainView struct ContentView: View {
let store: StoreOf<Main>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Button {
viewStore.send(.didTapButton)
} label: {
Text("Show Whisper")
}
}
// Getting these errors here:
// - Key path value type 'PresentationState<Main.Destination.State>' cannot be converted to contextual type 'Whisper.State'
// - Cannot convert value of type 'CasePath<Main.Action, PresentationAction<Main.Destination.Action>>' to expected argument type '(Whisper.Action) -> Main.Action'
.whisper(self.store.scope(
state: \.$destination,
action: /Main.Action.destination
))
}
}
} WhisperReducer public struct Whisper: Reducer {
private enum CancelID {
case timer
}
@Dependency(\.whisperDismissalTimeInSeconds) var dismissalTimeInSeconds
@Dependency(\.dismiss) var dismiss
public init() {}
public struct State: Identifiable, Equatable {
public let id: UUID
public let message: String
public let type: WhisperType
public init(
id: UUID,
message: String,
type: WhisperType
) {
self.id = id
self.message = message
self.type = type
}
}
public enum Action {
case didAppear
case userDidTap
case userDidClose
}
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .didAppear:
switch state.type.duration {
case .infinite:
return .cancel(id: CancelID.timer)
case .finite:
return .run { _ in
var tickCount = 0
while tickCount < 3 {
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
tickCount += 1
if tickCount == dismissalTimeInSeconds {
await self.dismiss()
}
}
}
.cancellable(id: CancelID.timer)
}
case .userDidTap, .userDidClose:
return .run { _ in
await self.dismiss()
}
}
}
} Thanks again for your time. |
Beta Was this translation helpful? Give feedback.
-
Ok, so finally I was able to achieve what I was looking for (check updated project for source code). My only problem right now is that there is a case, if user taps on the Whisper view that is dismissing using the default fadeOut animations, instead of using the offset change set on its reducer. The weird part is that Whisper has an internal timer to hide itself if is set, and in that case is working as expected, but if user taps on the Whisper, the This is my current code: WhisperView.swift struct WhisperView: View {
let viewStore: ViewStore<Whisper.State?, Whisper.Action>
let feedbackGenerator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
var body: some View {
if let state = viewStore.state {
ZStack {
HStack {
image(for: state.type)
.frame(width: 32, height: 32)
.padding(.leading)
Spacer()
closeButton(viewStore: viewStore)
.frame(width: 32, height: 32)
.padding(.trailing)
}
HStack {
Spacer()
Text(state.message)
.foregroundColor(.white)
.padding(.vertical)
.padding(.horizontal, 32)
Spacer()
}
}
.frame(minHeight: 56)
.background(Color.black)
.cornerRadius(8)
.padding(.horizontal)
.accessibilityIdentifier("WhisperView")
.accessibilityValue(state.message)
.onAppear {
UIAccessibility.post(notification: .announcement, argument: state.message)
feedbackGenerator.prepare()
viewStore.send(.didAppear, animation: .easeInOut)
}
.onTapGesture {
feedbackGenerator.impactOccurred()
viewStore.send(.userDidTap, animation: .easeInOut)
}
.offset(state.whisperOffset)
}
}
@ViewBuilder
private func image(
for type: WhisperType
) -> some View {
switch type {
case .error:
Image("error")
.renderingMode(.template)
.foregroundColor(.red)
case .success:
Image("success")
.renderingMode(.template)
.foregroundColor(.green)
}
}
@ViewBuilder
private func closeButton(
viewStore: ViewStore<Whisper.State?, Whisper.Action>
) -> some View {
Button {
feedbackGenerator.impactOccurred()
viewStore.send(.userDidClose, animation: .easeInOut)
} label: {
Image("close")
.renderingMode(.template)
.foregroundColor(.white)
}
.accessibilityIdentifier("Whisper.closeButton")
}
} Whisper.swift public struct Whisper: Reducer {
private enum CancelID {
case timer
}
@Dependency(\.whisperDismissalTimeInSeconds) var dismissalTimeInSeconds
@Dependency(\.dismiss) var dismiss
public init() {}
public struct State: Identifiable, Equatable {
public let id: UUID
public let message: String
public let type: WhisperType
internal let whisperHiddenOffset: CGSize = .init(width: 0, height: -150)
internal let whisperPresentedOffset: CGSize = .zero
internal var whisperOffset: CGSize
public init(
id: UUID,
message: String,
type: WhisperType
) {
self.id = id
self.message = message
self.type = type
self.whisperOffset = whisperHiddenOffset
}
}
public enum Action: Equatable {
case didAppear
case startInternalTimer
case userDidTap
case userDidClose
case dismiss
case updateWhisperOffset(offset: CGSize)
}
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .didAppear:
return .concatenate(
.run { [offset = state.whisperPresentedOffset] send in
await send(.updateWhisperOffset(offset: offset), animation: .spring())
},
.run { send in
await send(.startInternalTimer)
}
)
case .startInternalTimer:
switch state.type.duration {
case .infinite:
return .cancel(id: CancelID.timer)
case .finite:
return .run { send in
var tickCount = 0
while tickCount <= dismissalTimeInSeconds {
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
tickCount += 1
if tickCount == dismissalTimeInSeconds {
await send(.dismiss, animation: .easeInOut)
}
}
}
.cancellable(id: CancelID.timer)
}
case .userDidTap, .userDidClose:
return .run { send in
await send(.dismiss, animation: .easeInOut)
}
case .dismiss:
return .concatenate(
.run { [offset = state.whisperHiddenOffset] send in
await send(.updateWhisperOffset(offset: offset), animation: .easeInOut)
},
.run { _ in
// This delay is set to avoid call dismiss before animation is finished
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
await self.dismiss()
}
)
case let .updateWhisperOffset(offset):
state.whisperOffset = offset
return .none
}
}
} View+Whisper.swift public extension View {
func whisper<DestinationState, DestinationAction>(
_ store: Store<DestinationState?, PresentationAction<DestinationAction>>,
state toWhisperState: @escaping (DestinationState) -> Whisper.State?,
action fromWhisperAction: @escaping (Whisper.Action) -> DestinationAction
) -> some View {
self.modifier(
WhisperViewModifier(
store: store.scope(
state: {
$0.flatMap(toWhisperState)
},
action: {
switch $0 {
case .didAppear, .startInternalTimer, .updateWhisperOffset:
return .presented(fromWhisperAction($0))
case .userDidTap:
return .dismiss
case .userDidClose:
return .dismiss
case .dismiss:
return .dismiss
}
}
)
)
)
}
}
struct WhisperViewModifier: ViewModifier {
var store: Store<Whisper.State?, Whisper.Action>
init(store: Store<Whisper.State?, Whisper.Action>) {
self.store = store
}
func body(content: Content) -> some View {
WithViewStore(
store,
observe: { $0 },
removeDuplicates: { ($0 != nil) == ($1 != nil) }
) { viewStore in
ZStack(alignment: .top) {
content
.zIndex(0)
WhisperView(viewStore: viewStore)
.zIndex(1)
}
}
}
} |
Beta Was this translation helpful? Give feedback.
-
Ok, I was stupid, my problem was that I wasn't handling the action mapping properly. Instead of this: func whisper<DestinationState, DestinationAction>(
_ store: Store<DestinationState?, PresentationAction<DestinationAction>>,
state toWhisperState: @escaping (DestinationState) -> Whisper.State?,
action fromWhisperAction: @escaping (Whisper.Action) -> DestinationAction
) -> some View {
self.modifier(
WhisperViewModifier(
store: store.scope(
state: {
$0.flatMap(toWhisperState)
},
action: {
switch $0 {
case .didAppear, .startInternalTimer, .updateWhisperOffset:
return .presented(fromWhisperAction($0))
case .userDidTap:
return .dismiss
case .userDidClose:
return .dismiss
case .dismiss:
return .dismiss
}
}
)
)
)
} I had to do this func whisper<DestinationState, DestinationAction>(
_ store: Store<DestinationState?, PresentationAction<DestinationAction>>,
state toWhisperState: @escaping (DestinationState) -> Whisper.State?,
action fromWhisperAction: @escaping (Whisper.Action) -> DestinationAction
) -> some View {
self.modifier(
WhisperViewModifier(
store: store.scope(
state: {
$0.flatMap(toWhisperState)
},
action: {
switch $0 {
case .didAppear,
.startInternalTimer,
.updateWhisperOffset,
.userDidTap,
.userDidClose:
return .presented(fromWhisperAction($0))
case .dismiss:
return .dismiss
}
}
)
)
)
} I was forcing a |
Beta Was this translation helpful? Give feedback.
Ok, I was stupid, my problem was that I wasn't handling the action mapping properly.
Instead of this: