SwiftUI: Switch .sheet on enum, does not work - swiftui

I have two Modal/Popover .sheet's I would like to show based on which button is pressed by a user. I have setup an enum with the different choices and set a default choice.
Expected behaviour:
When the user selects any choice, the right sheet is displayed. When the user THEN selects the other choice, it also shows the correct sheet.
Observed behaviour:
In the example below, when the user first picks the second choice, the first sheet is shown and will continue to show until the user selects the first sheet, then it will start to switch.
Debug printing shows that the #State variable is changing, however, the sheet presentation does not observe this change and shows the sheets as described above. Any thoughts?
import SwiftUI
//MARK: main view:
struct ContentView: View {
//construct enum to decide which sheet to present:
enum ActiveSheet {
case sheetA, sheetB
}
//setup needed vars and set default sheet to show:
#State var activeSheet = ActiveSheet.sheetA //sets default sheet to Sheet A
#State var showSheet = false
var body: some View {
VStack{
Button(action: {
self.activeSheet = .sheetA //set choice to Sheet A on button press
print(self.activeSheet) //debug print current activeSheet value
self.showSheet.toggle() //trigger sheet
}) {
Text("Show Sheet A")
}
Button(action: {
self.activeSheet = .sheetB //set choice to Sheet B on button press
print(self.activeSheet) //debug print current activeSheet value
self.showSheet.toggle() //trigger sheet
}) {
Text("Show Sheet B")
}
}
//sheet choosing view to display based on selected enum value:
.sheet(isPresented: $showSheet) {
switch self.activeSheet {
case .sheetA:
SheetA() //present sheet A
case .sheetB:
SheetB() //present sheet B
}
}
}
}
//MARK: ancillary sheets:
struct SheetA: View {
var body: some View {
Text("I am sheet A")
.padding()
}
}
struct SheetB: View {
var body: some View {
Text("I am sheet B")
.padding()
}
}

With some very small alterations to your code, you can use sheet(item:) for this, which prevents this problem:
//MARK: main view:
struct ContentView: View {
//construct enum to decide which sheet to present:
enum ActiveSheet : String, Identifiable { // <--- note that it's now Identifiable
case sheetA, sheetB
var id: String {
return self.rawValue
}
}
#State var activeSheet : ActiveSheet? = nil // <--- now an optional property
var body: some View {
VStack{
Button(action: {
self.activeSheet = .sheetA
}) {
Text("Show Sheet A")
}
Button(action: {
self.activeSheet = .sheetB
}) {
Text("Show Sheet B")
}
}
//sheet choosing view to display based on selected enum value:
.sheet(item: $activeSheet) { sheet in // <--- sheet is of type ActiveSheet and lets you present the appropriate sheet based on which is active
switch sheet {
case .sheetA:
SheetA()
case .sheetB:
SheetB()
}
}
}
}
The problem is that without using item:, current versions of SwiftUI render the initial sheet with the first state value (ie sheet A in this case) and don't update properly on the first presentation. Using this item: approach solves the issue.

Related

swiftui how to find out if a sheet is currently being presented

I have an app with many nested views, some which show a sheet based on a user action.
But I also have a sheet that I'd like to present on the main view based on a timer (ie, not a user action). But you can't have 2 sheets at the same time, so I'd like to check "something" to see if a sheet is already up, and not present the one from the timer.
I'd like to do this in a general way, and not check every place in the code where a sheet might be presented.
Any suggestions?
Ideally there'd be something in the core framework that could be queried to answer the question "Is there a sheet being shown?", but as a commenter pointed out, that is fraught with peril.
So I just decided to leave it alone, that the "default" behavior is fine (ie, it'll defer presenting the sheet until any other sheet is dismissed). In my case this is preferred to any other gyrations.
EDIT:
Eek! I just found out that if the sheet from the timer is popped up while an Alert is showing...it ruins the app. Once you dismiss the alert, any attempt to bring up any sheet anywhere fails. It's as if things got out of sync somewhere. I believe this is similar to:
Lingering popover causes a problem with alerts
If you have alerts in your app, you don't really want to do this.
Here is how you can handle the sheets - the example below is fully functioning, just pass the view model to the environment before calling TabsView() in the App.
Create an Identifiable object that will handle all the sheets in the program:
// This struct can manage all sheets
struct CustomSheet: Identifiable {
let id = UUID()
let screen: TypeOfSheet
// All sheets should fit here
#ViewBuilder
var content: some View {
switch screen {
case .type1:
SheetType1()
case .type2(let text):
SheetType2(text: text)
default:
EmptyView()
}
}
// All types of sheets should fit here
enum TypeOfSheet {
case type1
case type2(text: String)
case none
}
}
Create one optional #Published var and one function in the view model; the var will tell the program what sheet is open:
// Code to be included in the view model, so it can
// handle AND track all the sheets
class MyViewModel: ObservableObject {
// This is THE variable that will tell the code whether a sheet is open
// (and also which one, if necessary)
#Published var sheetView: CustomSheet?
func showSheet(_ sheet: CustomSheet.TypeOfSheet) {
// Dismiss any sheet that is already open
sheetView = nil
switch sheet {
case .none:
break
default:
sheetView = CustomSheet(screen: sheet)
}
}
}
Usage:
open the sheets by calling the function viewModel.showSheet(...)
use .sheet(item:) to observe the type of sheet to open
use viewModel.sheet.screen to know what sheet is open
sheets can also be dismissed using viewModel.showSheet(.none)
// Example: how to use the view model to present and track sheets
struct TabsView: View {
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
TabView {
VStack {
Text("First tab. Sheet is \(String(describing: viewModel.sheetView?.screen ?? .none))")
.padding()
Button("Open sheet type 1") {
// Show a sheet of the first type
viewModel.showSheet(.type1)
}
}
.tabItem {Label("Tab 1", systemImage: "house")}
VStack {
Text("Second tab. Sheet is \(viewModel.sheetView == nil ? "Hidden" : "Shown")")
.padding()
Button("Open sheet type 2") {
// Show a sheet of the second type
viewModel.showSheet(.type2(text: "parameter"))
}
}
.tabItem {Label("Tab 2", systemImage: "plus")}
}
// Open a sheet - the one selected in the view model
.sheet(item: $viewModel.sheetView) { sheet in
sheet.content
.environmentObject(viewModel)
}
}
}
The following code completes the minimal reproducible example:
// Just some sample views for the sheets
struct SheetType1: View {
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
Text("Takes no parameters. Sheet is \(viewModel.sheetView == nil ? "Hidden" : "Shown")")
}
}
struct SheetType2: View {
#EnvironmentObject var viewModel: MyViewModel
let text: String
var body: some View {
Text("Takes a string: \(text). Sheet is \(String(describing: viewModel.sheetView?.screen ?? .none))")
}
}
#main
struct MyApp: App {
let viewModel = MyViewModel()
var body: some Scene {
WindowGroup {
TabsView()
.environmentObject(viewModel)
}
}
}

SwiftUI Picker selection not updated unless value shown in view (using Enum for selection)

import SwiftUI
enum TestEnum: String, CaseIterable {
case firstValue = "First Value"
case secondValue = "Second Value"
case thirdValue = "Third Value"
}
struct TestView: View {
#State private var testEnumSelection = TestEnum.allCases.first!
#State private var isShowingSheet = false
var body: some View {
VStack {
Picker("Test Enum Selection", selection: $testEnumSelection) {
ForEach(TestEnum.allCases, id: \.self) { testEnum in
Text(testEnum.rawValue)
}
}
//Text("Enum Selection: \(testEnumSelection.rawValue)") Enum value not updated if this line is not inlcuded
Button("Show Sheet", action: {
isShowingSheet = true
})
}
.padding()
.sheet(isPresented: $isShowingSheet) {
Text(testEnumSelection.rawValue)
.padding()
}
}
}
I am trying to use an enum value selected from a picker in a sheet view but the value from the picker is not being updated for the sheet. The value does get updated if I show the picker selection on screen elsewhere like in a Text object but I don't want to do that.
Could someone explain to me why I need to show the enum selection for it to be updated for the sheet and how to get around doing this?
The sheet content is created once, so it is not updated when state in parent is updated.
The possible solution is to separate sheet content into standalone view and use binding - bound variable will update view internals.
Here is a modified part (tested with Xcode 13.2 / macOS 12.1)
.padding()
.sheet(isPresented: $isShowingSheet) {
SheetContent(value: $testEnumSelection) // << here !!
}
}
}
struct SheetContent: View {
#Binding var value: TestEnum
var body: some View {
Text(value.rawValue)
.padding()
}
}

Get selected item in SwiftUI list without using a navigation link

I'm writing a SwiftUI Mac app that is similar to a kanban board. The app has three lists: Todo, Doing, and Done. At the bottom of each list is a button to move a task to another list. For example the todo list has a Start Doing button. Selecting a task from the todo list and clicking the button should move the task from the todo list to the doing list.
Every SwiftUI list selection example I have seen uses a navigation link. Selecting a list item takes you to another view. But I don't want to want to navigate to another view when selecting a list item. I want the selected task so I can change its status and move it to the correct list when clicking the button.
Here's the code for one of my lists.
struct TodoList: View {
// The board has an array of tasks.
#Binding var board: KanbanBoard
#State private var selection: Task? = nil
#State private var showAddSheet = false
var body: some View {
VStack {
Text("Todo")
.font(.title)
List(todoTasks, selection: $selection) { task in
Text(task.title)
}
HStack {
Button(action: { showAddSheet = true }, label: {
Label("Add", systemImage: "plus.square")
})
Spacer()
Button(action: { selection?.status = .doing}, label: {
Label("Start Doing", systemImage: "play.circle")
})
}
}
.sheet(isPresented: $showAddSheet) {
AddTaskView(board: $board)
}
}
var todoTasks: [Task] {
// Task conforms to Identifiable.
// A task has a status that is an enum: todo, doing, or done.
return board.tasks.filter { $0.status == .todo}
}
}
When I click on a list item, it is not selected.
How do I get the selected item from the list without using a navigation link?
Workaround
Tamas Sengel's answer led me to a workaround. Give each list item a Start Doing button so I don't have to track the selection.
List(todoTasks, id: \.self) { task in
HStack {
Text(task.title)
Button {
task.status = .doing
} label: {
Text("Start Doing")
}
}
}
The workaround helps for my specific case. But I'm going to keep the question open in hopes of an answer that provides a better alternative to using a button for people who want a way to get the selected list item.
Use a Button in the List and in the action, set a #State variable to the current list item.
#State var currentTask: Task?
List(todoTasks, id: \.self) { task in
Button {
currentTask = task
} label: {
Text(task.title)
}
}
Use .environment(\.editMode, .constant(.active)) to turn on selecting capability.
import SwiftUI
struct ContentView: View {
struct Ocean: Identifiable, Hashable {
let name: String
let id = UUID()
}
private var oceans = [
Ocean(name: "Pacific"),
Ocean(name: "Atlantic"),
Ocean(name: "Indian"),
Ocean(name: "Southern"),
Ocean(name: "Arctic")
]
#State private var multiSelection = Set<UUID>()
var body: some View {
NavigationView {
List(oceans, selection: $multiSelection) {
Text($0.name)
}
.navigationTitle("Oceans")
.environment(\.editMode, .constant(.active))
.onTapGesture {
// This is a walk-around: try how it works without `asyncAfter()`
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
print(multiSelection)
})
}
}
Text("\(multiSelection.count) selections")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Put your 3 List with same data array but filtering by status on each one something like:
task.filter({ $0.status == .toDo })
Then on your row add the modifier .onTapGesture be sure to cover all the available space.
Inside the code block introduce your logic or func to change the item status. changeTaskStatus(item: task)

Why does this SwiftUI code show "Empty" in the destination screen?

struct ContentView: View {
#State var showModal = false
#State var text = "Empty"
var body: some View {
Button("show text") {
text = "Filled"
showModal = true
}
.sheet(isPresented: $showModal) {
VStack {
Text(text)
Button("print text") {
print(text)
}
}
}
}
}
I thought that when the "show text" button was tapped, the value of text would be set to "Filled" and showModal would be set to true, so that the screen specified in sheet would be displayed and the word "Filled" would be shown on that screen.
I thought it would show "Filled", but it actually showed "Empty".
Furthermore, when I printed the text using the print text button, the console displayed "Filled".
Why does it work like this?
What am I missing to display the value I set when I tap the button on the destination screen?
using Xcode12.4, Xcode12.5
Add the code for the new pattern.
struct ContentView: View {
#State var number = 0
#State var showModal = false
var body: some View {
VStack {
Button("set number 1") {
number = 1
showModal = true
print("set number = \(number)")
}
Button("set number 2") {
number = 2
showModal = true
print("set number = \(number)")
}
Button("add number") {
number += 1
showModal = true
print("add number = \(number)")
}
}
.sheet(isPresented: $showModal) {
VStack {
let _ = print("number = \(number)")
Text("\(number)")
}
}
}
}
In the above code, when I first tap "set number 1" or "set number 2", the destination screen shows "0". No matter how many times you tap the same button, "0" will be displayed.
However, if you tap "set number 2" after tapping "set number 1", it will work correctly and display "2". If you continue to tap "set number 1", "1" will be displayed and the app will work correctly.
When you tap "add number" for the first time after the app is launched, "0" will still be displayed, but if you tap "add number" again, "2" will be displayed and the app will count up correctly.
This shows that the rendering of the destination screen can be started even when the #State variable is updated, but only when the #State variable is referenced first in the destination screen, it does not seem to be referenced properly.
Can anyone explain why it behaves this way?
Or does this look like a bug in SwiftUI?
Since iOS 14, there are a couple of main ways of presenting a Sheet.
Firstly, for your example, you need to create a separate View and pass your property to a Binding, which will then be correctly updated when the Sheet is presented.
// ContentView
Button { ... }
.sheet(isPresented: $showModal) {
SheetView(text: $text)
}
struct SheetView: View {
#Binding var text: String
var body: some View {
VStack {
Text(text)
Button("print text") {
print(text)
}
}
}
}
The other way of doing it is by using an Optional identifiable object, and when that object has a value the sheet will be presented. Doing that, you do not need to separately manage the state of whether the sheet is showing.
struct Item: Identifiable {
var id = UUID()
var text: String
}
struct ContentView: View {
#State var item: Item? = nil
var body: some View {
Button("show text") {
item = Item(text: "Filled")
}
.sheet(item: $item, content: { item in
VStack {
Text(item.text)
Button("print text") {
print(item.text)
}
}
})
}
}
By adding the following code, I was able to display the word "Filled" in the destination screen.
Declare the updateDetector as a #State variable.
Update the updateDetector onAppear of the View in the sheet.
Reference the updateDetector somewhere in the View in the sheet.
struct ContentView: View {
#State var showModal = false
#State var updateDetector = false
#State var text = "Empty"
var body: some View {
Button("show text") {
text = "Filled"
print("set text to \(text)")
showModal = true
}
.sheet(isPresented: $showModal) {
let _ = print("render text = \(text)")
VStack {
Text(text)
// use EmptyView to access updateDetector
if updateDetector {
EmptyView()
}
}
.onAppear {
// update updateDetector
print("toggle updateDetector")
updateDetector.toggle()
}
}
}
}
Then, after rendering the initial value at the time of preloading, the process of updating the updateDetector onAppear will work, the View will be rendered again, and the updated value of the #State variable will be displayed.
When you tap the "show text" button in the above code, the following will be displayed in the console and "Filled" will be shown on the screen.
set text to Filled
render text = Empty
toggle updateDetector
render text = Filled
I'm still not convinced why I can't get the updated value of the #State variable in the first preloaded sheet. So I will report this to Apple via the Feedback Assistant.

SwiftUI: .sheet() doesn't go to the previous view with expected data when dismiss current sheet

As minimal, my code is like below. In SinglePersonView When user tap one image of movie in MovieListView(a movie list showing actor attended movies), then it opens the SingleMovieView as sheet mode.
The sheet could be popped up as tapping. But I found after close the sheet and re-select other movie in MovieListView, the sheet always opened as my previous clicked movie info aka the first time chosen one. And I could see in console, the movie id is always the same one as the first time. I get no clues now, do I need some reloading operation on the dismissal or something else?
And is it the correct way to use .sheet() in subView in SwiftUI, or should always keep it in the main body, SinglePersonView in this case.
struct SinglePersonView: View {
var personId = -1
#ObservedObject var model = MovieListViewModel()
var body: some View {
ScrollView() {
VStack() {
...
MovieListView(movies: model.movies)
...
}
}.onAppear {
// json API request
}
}
}
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.showSheet.toggle()
}
.sheet(isPresented: self.$showSheet) {
SingleMovieView(movieId: movie.id)
}
}
}
}
}
}
There should be only one .sheet in view stack, but in provided snapshot there are many which activated all at once - following behaviour is unpredictable, actually.
Here is corrected variant
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
#State private var selectedID = "" // type of your movie's ID
var body: some View {
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.selectedID = movie.id
self.showSheet.toggle()
}
}
}
}
.sheet(isPresented: $showSheet) {
SingleMovieView(movieId: selectedID)
}
}
}
}