The problem is that the picker value sticks only on the first element, when I try to scroll, it comes back again with an annoying glitch, tried to do the tagging by rawValue of the enum, also did without ForEach, manually added Text elements for all cases of the enum, nothing works.
extension HabbitEditScreen {
private class ViewModel: ObservableObject {
#Published var controller: UIViewController?
#Published var value: String = ""
#Published var repeatSelection: HabbitRepition = .everyDay
}
}
enum HabbitRepition: String, CaseIterable {
case everyDay = "Every Day"
case monWedFri = "Mon, Wed, Fri"
case thuesThursSat = "Thues, Thurs, Sat"
}
private var repeatPickerView: some View {
VStack {
Button(action: { showRepeatPicker = false }) {
HStack {
Spacer()
Text("Done")
}
}
Picker("Select", selection: $vm.repeatSelection) {
ForEach(HabbitRepition.allCases, id: \.self) { value in
Text(value.rawValue).tag(value)
}
}
.pickerStyle(.wheel)
.frame(maxHeight: 120)
}
}
Related
In a macOS SwiftUI app, I have a List of items with context menus. When a menu selection is made, the app needs to act on the correct list item. (The context menu can apply to any item, not just the selected one.)
I have a solution that works fairly well, but it has a strange bug. When you right click (or Command+click) on an item, the app sets a variable indicating which item was clicked, and also sets a flag. The flag triggers a sheet requesting confirmation of the action. The problem is that the first time you select a menu item, the sheet doesn’t use the saved item as it should. You can see because the item’s name is not in the “Ok to delete” prompt. If you close that first sheet and select another item, it works correctly, and it works for for every subsequent item from then on, even the first one you tried. It doesn’t matter which item you try first, or whether you select the item first, or anything.
import SwiftUI
struct ContentView: View {
#State private var actionTarget = Value(name: "")
#State private var isDeleting = false
#State private var selection = Value(name: "")
struct Value: Identifiable, Hashable {
let id = UUID()
var name: String
}
let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
var body: some View {
List(values, selection: $selection) { value in
Text (value.name)
.tag(value)
.contextMenu(ContextMenu {
Button {
actionTarget = value
isDeleting = true
} label: { Text("Delete \(value.name)") }
})
}
.sheet(isPresented: $isDeleting) {
Text("Ok to delete \"\(actionTarget.name)?\"")
.frame(width: 300)
.padding()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { isDeleting = false }
}
ToolbarItem(placement: .destructiveAction) {
Button {
//TODO: Delete
isDeleting = false
} label: { Text("Delete") }
}
}
}
}
}
This is a bug in SwiftUI.
You can work around it by using a different version of the sheet modifier, the one that takes a Binding<Item?>. That also has the advantage that it leads you to a better data model. In your model as posted, you have separate isDeleting and actionTarget variables which can be out of sync. Instead, use a single optional variable holding the Value to be deleted, or nil if there is no deletion to be confirmed.
struct ContentView: View {
#State private var deleteRequest: Value? = nil
#State private var selection: Value? = nil
struct Value: Identifiable, Hashable {
let id = UUID()
var name: String
}
let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
var body: some View {
List(values, selection: $selection) { value in
Text(value.name)
.tag(value)
.contextMenu(ContextMenu {
Button {
deleteRequest = value
} label: { Text("Delete \(value.name)") }
})
}
.sheet(
item: $deleteRequest,
onDismiss: { deleteRequest = nil }
) { item in
Text("Ok to delete \"\(item.name)?\"")
.frame(width: 300)
.padding()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
deleteRequest = nil
}
}
ToolbarItem(placement: .destructiveAction) {
Button {
print("TODO: delete \(item)")
deleteRequest = nil
} label: { Text("Delete") }
}
}
}
}
}
But the use of a toolbar inside the sheet doesn't look like a normal macOS confirmation sheet. Instead, you should use confirmationDialog.
struct ContentView: View {
#State private var deleteRequest: Value? = nil
#State private var selection: Value? = nil
struct Value: Identifiable, Hashable {
let id = UUID()
var name: String
}
let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
var body: some View {
List(values, selection: $selection) { value in
Text(value.name)
.tag(value)
.contextMenu(ContextMenu {
Button {
deleteRequest = value
} label: { Text("Delete \(value.name)") }
})
}
.confirmationDialog(
"OK to delete \(deleteRequest?.name ?? "(nil)")?",
isPresented: .constant(deleteRequest != nil),
presenting: deleteRequest,
actions: { item in
Button("Cancel", role: .cancel) { deleteRequest = nil }
Button("Delete", role: .destructive) {
print("TODO: delete \(item)")
deleteRequest = nil
}
}
)
}
}
I'm trying to show different views (with the same base) depending on an enum value but depending on how to "inspect" the enum the behavior changes. This is the code (I'm using a "useSwitch" variable to be able to alternate between both behaviors)
import SwiftUI
enum ViewType: CaseIterable {
case type1
case type2
var text: String {
switch self {
case .type1:
return "Type 1"
case .type2:
return "Type 2"
}
}
}
final class BaseVM: ObservableObject {
let type: ViewType
#Published var requestingData = false
init(type: ViewType) {
self.type = type
}
#MainActor func getData() async {
requestingData = true
try! await Task.sleep(nanoseconds: 1_000_000_000)
requestingData = false
}
}
struct BaseView: View {
#StateObject var vm: BaseVM
var body: some View {
Group {
if vm.requestingData {
ProgressView("Getting data for \(vm.type.text)")
} else {
Text("\(vm.type.text)")
}
}
.onAppear {
Task {
await vm.getData()
}
}
}
}
struct TestZStackView: View {
private let types = ViewType.allCases
#State var currentType: ViewType = .type1
private var useSwitch = true
var body: some View {
VStack {
if useSwitch {
Group {
switch currentType {
case .type1:
BaseView(vm: BaseVM(type: currentType))
case .type2:
BaseView(vm: BaseVM(type: currentType))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
BaseView(vm: BaseVM(type: currentType))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Spacer()
Picker("", selection: $currentType) {
ForEach(types, id: \.self) {
Text($0.text)
}
}
.pickerStyle(.segmented)
.padding(.top, 20)
}
.padding()
}
}
struct TestZStackView_Previews: PreviewProvider {
static var previews: some View {
TestZStackView()
}
}
I don't understand why using a switch (useSwitch == true) refreshes the view but using the constructor passing the enum as parameter (useSwitch = false) doesn't refresh the view... It can't detect that the currentType has changed if used as parameter instead of checking it using a switch?
This is all about identity. If you need more information I would recommend watching WWDC Demystify SwiftUI.
If your #State var triggers when changing the Picker the TestZStackView rebuilds itself. When hitting the if/else clause there are two possibilities:
private var useSwitch = true. So it checks the currentType and builds the appropriate BaseView. These differ from each other in their id, so a new View gets build and you get what you expect.
the second case is less intuitive. I really recommend watching that WWDC session mentioned earlier. If private var useSwitch = false there is no switch statement and SwiftUI tries to find out if your BaseView has changed and needs to rerender. For SwiftUI your BaseView hasn´t changed even if you provided a new BaseVM. It does notify only changes on depending properties or structs (or #Published in ObservableObject).
In your case #StateObject var vm: BaseVM is the culprit. But removing #StateObject will create the new View but you loose the ObservableObject functionality.
Solution here would be to restructure your code. Use only one BaseVm instance that holds your state and pass that on into the environment.
E.g.:
final class BaseVM: ObservableObject {
// create a published var here
#Published var type: ViewType = .type1
#Published var requestingData = false
#MainActor func getData() async {
requestingData = true
try! await Task.sleep(nanoseconds: 1_000_000_000)
requestingData = false
}
}
struct BaseView: View {
// receive the viewmodel from the environment
#EnvironmentObject private var vm: BaseVM
var body: some View {
Group {
if vm.requestingData {
ProgressView("Getting data for \(vm.type.text)")
} else {
Text("\(vm.type.text)")
}
}
// change this also because the view will not apear multiple times it
// will just change depending on the type value
.onChange(of: vm.type) { newValue in
Task{
await vm.getData()
}
}.onAppear{
Task{
await vm.getData()
}
}
}
}
struct TestZStackView: View {
private let types = ViewType.allCases
#StateObject private var viewmodel = BaseVM()
private var useSwitch = false
var body: some View {
VStack {
if useSwitch {
//this group doesn´t really make sense but just for demonstration
Group {
switch viewmodel.type {
case .type1:
BaseView()
.environmentObject(viewmodel)
case .type2:
BaseView()
.environmentObject(viewmodel)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
BaseView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environmentObject(viewmodel)
}
Spacer()
Picker("", selection: $viewmodel.type) {
ForEach(types, id: \.self) {
Text($0.text)
}
}
.pickerStyle(.segmented)
.padding(.top, 20)
}
.padding()
}
}
I'm trying to build a comment thread. So top level comments can all have nested comments and so can they and so on and so forth. But I'm having issues around scrolling and also sometimes when expanding sections the whole view just jumps around, and can have a giant blank space at the bottom. The code looks like this:
struct ContentView: View {
var body: some View {
VStack {
HStack {
Text("Comments")
.font(.system(size: 34))
.fontWeight(.bold)
Spacer()
}
.padding()
CommentListView(commentIds: [0, 1, 2, 3], nestingLevel: 1)
}
}
}
struct CommentListView: View {
let commentIds: [Int]?
let nestingLevel: Int
var body: some View {
if let commentIds = commentIds {
LazyVStack(alignment: .leading) {
ForEach(commentIds, id: \.self) { id in
CommentItemView(viewModel: CommentItemViewModel(commentId: id), nestingLevel: nestingLevel)
}
}
.applyIf(nestingLevel == 1) {
$0.scrollable()
}
} else {
Spacer()
Text("No comments")
Spacer()
}
}
}
struct CommentItemView: View {
#StateObject var viewModel: CommentItemViewModel
let nestingLevel: Int
#State private var showComments = false
var body: some View {
VStack {
switch viewModel.viewState {
case .error:
Text("Error")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .loading:
Text("Loading")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .complete:
VStack {
Text(viewModel.text)
.padding(.bottom)
.padding(.leading, 20 * CGFloat(nestingLevel))
if let commentIds = viewModel.commentIds {
Button {
withAnimation {
showComments.toggle()
}
} label: {
Text(showComments ? "Hide comments" : "Show comments")
}
if showComments {
CommentListView(commentIds: commentIds, nestingLevel: nestingLevel + 1)
}
}
}
}
}
}
}
class CommentItemViewModel: ObservableObject {
#Published private(set) var text = ""
#Published private(set) var commentIds: [Int]? = [0, 1, 2, 3]
#Published private(set) var viewState: ViewState = .loading
private let commentId: Int
private var viewStateInternal: ViewState = .loading {
willSet {
withAnimation {
viewState = newValue
}
}
}
init(commentId: Int) {
self.commentId = commentId
fetchComment()
}
private func fetchComment() {
viewStateInternal = .complete
text = CommentValue.allCases[commentId].rawValue
}
}
Has anyone got a better way of doing this? I know List can now accept a KeyPath to child object and it can nest that way, but there's so limited design control over List that I didn't want to use it. Also, while this code is an example, the real code will have to load each comment from an API call, so List won't perform as well as LazyVStack in that regard.
Any help appreciated - including a complete overhaul of how to implement this sort of async loading nested view.
I have an issue using a sheet inside a ForEach. Basically I have a List that shows many items in my array and an image that trigger the sheet. The problem is that when my sheet is presented it only shows the first item of my array which is "Harry Potter" in this case.
Here's the code
struct ContentView: View {
#State private var showingSheet = false
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(0 ..< movies.count) { movie in
HStack {
Text(self.movies[movie])
Image(systemName: "heart")
}
.onTapGesture {
self.showingSheet = true
}
.sheet(isPresented: self.$showingSheet) {
Text(self.movies[movie])
}
}
}
}
}
}
There should be only one sheet, so here is possible approach - use another sheet modifier and activate it by selection
Tested with Xcode 12 / iOS 14 (iOS 13 compatible)
extension Int: Identifiable {
public var id: Int { self }
}
struct ContentView: View {
#State private var selectedMovie: Int? = nil
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(0 ..< movies.count) { movie in
HStack {
Text(self.movies[movie])
Image(systemName: "heart")
}
.onTapGesture {
self.selectedMovie = movie
}
}
}
.sheet(item: self.$selectedMovie) {
Text(self.movies[$0])
}
}
}
}
I changed your code to have only one sheet and have the selected movie in one variable.
extension String: Identifiable {
public var id: String { self }
}
struct ContentView: View {
#State private var selectedMovie: String? = nil
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(movies) { movie in
HStack {
Text(movie)
Image(systemName: "heart")
}
.onTapGesture {
self.selectedMovie = movie
}
}
}
.sheet(item: self.$selectedMovie, content: { selectedMovie in
Text(selectedMovie)
})
}
}
}
Wanted to give my 2 cents on the matter.
I was encountering the same problem and Asperi's solution worked for me.
BUT - I also wanted to have a button on the sheet that dismisses the modal.
When you call a sheet with isPresented you pass a binding Bool and so you change it to false in order to dismiss.
What I did in the item case is I passed the item as a Binding. And in the sheet, I change that binding item to nil and that dismissed the sheet.
So for example in this case the code would be:
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(0 ..< movies.count) { movie in
HStack {
Text(self.movies[movie])
Image(systemName: "heart")
}
.onTapGesture {
self.selectedMovie = movie
}
}
}
.sheet(item: self.$selectedMovie) {
Text(self.movies[$0])
// My addition here: a "Done" button that dismisses the sheet
Button {
selectedMovie = nil
} label: {
Text("Done")
}
}
}
}
I am having some trouble avoiding index out of range errors when modifying an array that a child view depends on a bound object from.
I have a parent view called WorkoutList. WorkoutList has an EnvironmentObject of ActiveWorkoutStore. ActiveWorkoutStore is an ObservableObject that has an array of Workout objects. I have a list of active workouts being retrieved from ActiveWorkoutStore. I'm using a ForEach loop to work with the indices of these active workouts and pass an object binding to a child view called EditWorkout as a destination for a NavigationLink. EditWorkout has a button to finish a workout, which removes it from ActiveWorkoutStore's array of workouts and adds it to WorkoutHistoryStore. I'm running into trouble when I remove this object from ActiveWorkoutStore's activeWorkouts array, immediately causing an index out of range error. I'm suspecting this is because the active view relies on a bound object that I've just deleted. I've tried a couple permutations of this, including passing a workout to EditWorkout, then using its id to reference a workout in ActiveWorkoutStore to perform my operations, but run into similar troubles. I've seen a lot of examples online that follow this pattern of leveraging ForEach to iterate over indices and I've mirrored it as best I can tell, but I suspect I may be missing a nuance to the approach.
I've attached code samples below. Let me know if you have any questions or if there's anything else I should include! Thanks in advance for your help!
WorkoutList (Parent View)
import SwiftUI
struct WorkoutList: View {
#EnvironmentObject var activeWorkoutsStore: ActiveWorkoutStore
#State private var addExercise = false
#State private var workoutInProgress = false
var newWorkoutButton: some View {
Button(action: {
self.activeWorkoutsStore.newActiveWorkout()
}) {
Text("New Workout")
Image(systemName: "plus.circle")
}
}
var body: some View {
NavigationView {
Group {
if activeWorkoutsStore.activeWorkouts.isEmpty {
Text("No active workouts")
} else {
List {
ForEach(activeWorkoutsStore.activeWorkouts.indices.reversed(), id: \.self) { activeWorkoutIndex in
NavigationLink(destination: EditWorkout(activeWorkout: self.$activeWorkoutsStore.activeWorkouts[activeWorkoutIndex])) {
Text(self.activeWorkoutsStore.activeWorkouts[activeWorkoutIndex].id.uuidString)
}
}
}
}
}
.navigationBarTitle(Text("Active Workouts"))
.navigationBarItems(trailing: newWorkoutButton)
}
}
}
EditWorkout (Child View)
//
// EditWorkout.swift
// workout-planner
//
// Created by Dominic Minischetti III on 11/2/19.
// Copyright © 2019 Dominic Minischetti. All rights reserved.
//
import SwiftUI
struct EditWorkout: View {
#EnvironmentObject var workoutHistoryStore: WorkoutHistoryStore
#EnvironmentObject var activeWorkoutStore: ActiveWorkoutStore
#EnvironmentObject var exerciseStore: ExerciseStore
#Environment(\.presentationMode) var presentationMode
#State private var addExercise = false
#Binding var activeWorkout: Workout
var currentDayOfWeek: String {
let weekdayIndex = Calendar.current.component(.weekday, from: Date())
return Calendar.current.weekdaySymbols[weekdayIndex - 1]
}
var chooseExercisesButton: some View {
Button (action: {
self.addExercise = true
}) {
HStack {
Image(systemName: "plus.square")
Text("Choose Exercises")
}
}
.sheet(isPresented: self.$addExercise) {
AddWorkoutExercise(exercises: self.$activeWorkout.exercises)
.environmentObject(self.exerciseStore)
}
}
var saveButton: some View {
Button(action: {
self.workoutHistoryStore.addWorkout(workout: self.$activeWorkout.wrappedValue)
self.activeWorkoutStore.removeActiveWorkout(workout: self.$activeWorkout.wrappedValue)
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Finish Workout")
}
.disabled(self.$activeWorkout.wrappedValue.exercises.isEmpty)
}
var body: some View {
Form {
Section(footer: Text("Choose which exercises are part of this workout")) {
chooseExercisesButton
}
Section(header: Text("Exercises")) {
if $activeWorkout.wrappedValue.exercises.isEmpty {
Text("No exercises")
} else {
ForEach(activeWorkout.exercises.indices, id: \.self) { exerciseIndex in
NavigationLink(destination: EditWorkoutExercise(exercise: self.$activeWorkout.exercises[exerciseIndex])) {
VStack(alignment: .leading) {
Text(self.activeWorkout.exercises[exerciseIndex].name)
Text("\(self.activeWorkout.exercises[exerciseIndex].sets.count) Set\(self.activeWorkout.exercises[exerciseIndex].sets.count == 1 ? "" : "s")")
.font(.footnote)
.opacity(0.5)
}
}
}
saveButton
}
}
}
.navigationBarTitle(Text("Edit Workout"), displayMode: .inline )
}
}
ActiveWorkoutStore
import Foundation
import Combine
class ActiveWorkoutStore: ObservableObject {
#Published var activeWorkouts: [Workout] = []
func newActiveWorkout() {
activeWorkouts.append(Workout())
}
func saveActiveWorkout(workout: Workout) {
let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id })!
activeWorkouts[workoutIndex] = workout
}
func removeActiveWorkout(workout: Workout) {
if let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id }) {
activeWorkouts.remove(at: workoutIndex)
}
}
}
Workout
import SwiftUI
struct Workout: Hashable, Codable, Identifiable {
var id = UUID()
var date = Date()
var exercises: [WorkoutExercise] = []
}
ForEach<Range> is constant range container (pay attention on below description of constructor), it is not allowed to modify it after construction.
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a *constant*
/// range.
///
/// This instance only reads the initial value of `data` and so it does not
/// need to identify views across updates.
///
/// To compute views on demand over a dynamic range use
/// `ForEach(_:id:content:)`.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
If you want to modify container, you have to use ForEach(activeWorkout.exercises)