I want to display a certain form in the app based on a Picker selected value. However, when I switch using the segmented control fast enough (here it seems forced, but when the form is more complex it's very noticeable). Edit: this seems to be happening with all Views, not just forms.
import SwiftUI
struct ContentView: View {
#State private var calculationType = CalculationType.months
#State private var balanceOwned: String = ""
var body: some View {
VStack {
Picker("Calculation Type", selection: $calculationType) {
ForEach(CalculationType.allCases, id: \.self) {
Text($0.rawValue.capitalized)
}
}.pickerStyle(SegmentedPickerStyle())
// This check seems to be the cause of the problem!
if calculationType == .months {
CustomForm(balanceOwned: $balanceOwned)
} else if calculationType == .fixed {
CustomForm(balanceOwned: $balanceOwned)
} else {
CustomForm(balanceOwned: $balanceOwned)
}
}
}
}
struct CustomForm: View {
#Binding var balanceOwned: String
var body: some View {
Form {
Section {
TextField("Test", text: $balanceOwned)
.foregroundColor(.white)
}
}
}
}
enum CalculationType: String, CaseIterable {
case months
case fixed
case minimum
}
Whatever is inside the form flickers. How do I fix it?
You are facing structural identity problem, when dealing with conditions,
If you want to reuse same CustomForm and data can be populated based on balanceOwned, then need to preserve identity.
Either you give explicit identity or refactor structure to preserve structural identity of SwiftUI view.
Explicit Identity:
var body: some View {
....
Picker("Calculation Type", selection: $calculationType) {
ForEach(CalculationType.allCases, id: \.self) {
Text($0.rawValue.capitalized)
}
}
.onChange(of: calculationType, perform: pickerChange(_:))
.pickerStyle(SegmentedPickerStyle())
if calculationType == .months {
CustomForm(balanceOwned: $balanceOwned)
.id("11") // Common hashable explicit identity used.
} else if calculationType == .fixed {
CustomForm(balanceOwned: $balanceOwned)
.id("11")
} else {
CustomForm(balanceOwned: $balanceOwned)
.id("11")
}
...
}
Structural Identity:
var body: some View {
...
Picker("Calculation Type", selection: $calculationType) {
ForEach(CalculationType.allCases, id: \.self) {
Text($0.rawValue.capitalized)
}
}
.onChange(of: calculationType, perform: pickerChange(_:))
.pickerStyle(SegmentedPickerStyle())
CustomForm(balanceOwned: $balanceOwned) // Preserve structural identity
...
}
func pickerChange(_ type: CalculationType) {
switch type {
case .months:
balanceOwned = "Months"
case .fixed:
balanceOwned = "Fixed"
case .minimum:
balanceOwned = "Minimum"
}
}
Related
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)
}
}
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 have a movie listing view with basic listing functionality, Once pagination reaches to the last page I want to show an alert for that I am using reachedLastPage property.
The viewModel.state is an enum, the case movies has associated value in which there is moreRemaining property which tells if there are more pages or not.
Once the moreRemaining property becomes false I want to make reachedLastPage to true so that I can show an alert.
How can I achieve this in best way?
import SwiftUI
import SwiftUIRefresh
struct MovieListingView<T>: View where T: BaseMoviesListViewModel {
#ObservedObject var viewModel: T
#State var title: String
#State var reachedLastPage: Bool = false
var body: some View {
NavigationView {
ZStack {
switch viewModel.state {
case .loading:
LoadingView(title: "Loading Movies...")
.onAppear {
fetchMovies()
}
case .error(let error):
ErrorView(message: error.localizedDescription, buttonTitle: "Retry") {
fetchMovies()
}
case .noData:
Text("No data")
.multilineTextAlignment(.center)
.font(.system(size: 20))
case .movies(let data):
List {
ForEach(data.movies) { movie in
NavigationLink(destination: LazyView(MovieDetailView(viewModel: MovieDetailViewModel(id: movie.id)))) {
MovieViewRow(movie: movie)
.onAppear {
if movie == data.movies.last && data.moreRemaining {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
fetchMovies()
}
}
}
}
if movie == data.movies.last && data.moreRemaining {
HStack {
Spacer()
ActivityIndicator(isAnimating: .constant(data.moreRemaining))
Spacer()
}
}
}
}.pullToRefresh(isShowing: .constant(data.isRefreshing)) {
print("Refresheeeee")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
refreshMovies()
}
}
}
}
.navigationViewStyle(.stack)
.navigationBarTitle("\(title)", displayMode: .inline)
.alert(isPresented: $reachedLastPage) {
Alert(title: Text("You have reached to the end of the list."))
}
}
}
private func fetchMovies() {
viewModel.trigger(.fetchMovies(false))
}
private func refreshMovies() {
viewModel.trigger(.fetchMovies(true))
}
}
you could try this approach, using .onReceive(...). Add this to your
ZStack or NavigationView:
.onReceive(Just(viewModel.moreRemaining)) { val in
reachedLastPage = !val
}
Also add: import Combine
(Ignoring "the best way" part, 'cause it's opinion-based,) one way to achieve that is to make your view model an observable object (which likely already is), adding the publisher of reachedLastPage there, and observe it directly from the view. Something like this:
final class ContentViewModel: ObservableObject {
#Published var reachedLastPage = false
init() {
// Just an example of changing the value.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { self.reachedLastPage = true }
}
}
struct ContentView: View {
var body: some View {
Text("Hello World")
.alert(isPresented: $viewModel.reachedLastPage) {
Alert(title: Text("Alert is triggered"))
}
}
#ObservedObject private var viewModel: ContentViewModel
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
}
Once reachedLastPage takes the true value, the alert will be presented.
Testing this piece of code with Xcode 14 beta 5. Everything is working properly (sorting items with an animation, plus saving the selected sortOrder in UserDefaults).
However the if-else condition in the Menu's label seems to be ignored and the label is not updated. Please do you know why? Or what is alternate solution to avoid this?
struct ContentView: View {
enum SortOrder: String, CaseIterable, Identifiable {
case forward
case reverse
var id: Self {
self
}
static let `default`: Self = .forward
var label: String {
switch self {
case .forward: return "Sort (forward)"
case .reverse: return "Sort (reverse)"
}
}
}
#AppStorage("sortOrder") var sortOrder: SortOrder = .default {
didSet {
sort()
}
}
#State private var items = ["foo", "bar", "baz"]
#ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem {
Menu {
ForEach(SortOrder.allCases) { sortOrder in
Button {
withAnimation { self.sortOrder = sortOrder }
} label: {
// FIXME: doesn't reflect sortOrder value
if sortOrder == self.sortOrder {
Label(sortOrder.label, systemImage: "checkmark")
} else {
Text(sortOrder.label)
}
}
}
} label: {
Label("Actions", systemImage: "ellipsis.circle")
}
}
}
var body: some View {
NavigationStack {
List(items, id: \.self) { item in
Text(item)
}
.navigationTitle("Test")
.toolbar { toolbar }
.onAppear(perform: sort)
}
}
func sort() {
switch sortOrder {
case .forward:
items.sort(by: <)
case .reverse:
items.sort(by: >)
}
}
}
It is selected but Menu is not updated, assuming in toolbar it should be persistent.
A possible workaround is to force-rebuild menu on sort change, like
Menu {
// ...
} label: {
Label("Actions", systemImage: "ellipsis.circle")
}
.id(self.sortOrder) // << here !!
Tested with Xcode 14b5 / iOS 16
The if-else in your ToolbarItem is working correctly. The problem here is that .labelStyle in a toolbar are defaulted to .titleOnly.
To display the label with both icon and title, you would need to add .labelStyle(.titleAndIcon).
The solution was simply to use a Picker with .pickerStyle modifier set to .menu, instead of a hardcoded Menu.
var toolbar: some ToolbarContent {
ToolbarItem {
Picker(selection: $selectedSort) {
ForEach(Sort.allCases) { sort in
Text(sort.localizedTitle).tag(sort)
}
} label: {
Label("Sort by", systemImage: "arrow.up.arrow.down")
}
.pickerStyle(.menu)
}
}
Now SwiftUI automatically updates the interface.
Trying to following this discussion, I implemented the suggestion of Yurii Kotov:
struct ContentView: View {
#State private var index = 0
#ViewBuilder
var body: some View {
if index == 0 {
MainView()
} else {
LoginView()
}
}
It works fine. But if I try to use a switch statement instead:
switch index {
case 0: MainView()
case 1: LoginView()
default:
print("# error in switch")
}
nothing happens. There is no mistake alert, but also no result at all. Could somebody help?
I had an issue with the default case where I wanted a "break" type situation for the switch to be exhaustive. SwiftUI requires some type of view, I found that
EmptyView() solved the issue.
also noted here EmptyView Discussion that you "have to return something"
struct FigureListMenuItems: View {
var showContextType: ShowContextEnum
#Binding var isFavoriteSeries: Bool?
var body: some View {
Menu {
switch showContextType {
case .series:
Button(action: { toggleFavoriteSeries() }) {
isFavoriteSeries! ?
Label("Favorite Series?", systemImage: "star.fill")
.foregroundColor(.yellow)
:
Label("Favorite Series?", systemImage: "star")
.foregroundColor(.yellow)
}
default: // <-- can use EmptyView() in this default too if you want
Button(action: {}) {
Text("No menu items")
}
}
} label: {
switch showContextType {
case .series:
isFavoriteSeries! ?
Image(systemName: "star.fill")
.foregroundColor(.yellow)
:
Image(systemName: "star")
.foregroundColor(.yellow)
default:
EmptyView()
}
Label("Menu", systemImage: "ellipsis.circle")
}
}
private func toggleFavoriteSeries() {
isFavoriteSeries?.toggle()
}
}
Enum for switch
enum ShowContextEnum: Int {
case series = 1
case whatIsNew = 2
case allFigures = 3
}
As #Sweeper said: your if...else and switch...case statements are not equal. The main idea is: body is just a computed variable of a View protocol and it should return something of its' type. Sure, you can do it with switch...case statements. In your code snippet mistake is in default statement: body cannot return() -> Void, only some View. So your code should looks like this:
struct ViewWithSwitchStatements: View {
#State var option = 0
var body: some View {
switch option {
case 1:
return AnyView(Text("Option 1"))
case 2:
return AnyView(Text("Option 2"))
default:
return AnyView(Text("Wrong option!"))
}
}
}
However you can't put it into VStack or something else, like if...else statements.