SwiftUI - Using a Picker on another view to sort results from API call - filter

I have a view that makes an API call to pull nearby restaurant data and display it in a list. I've placed a button in the navigation bar of that view to display a sheet which will ultimately show the user filter options. For now, it uses a Picker placed in a form to allow the user to pick how they want to sort the results. I am using a variable with a #Binding property wrapper in the filter view to pass the selected value to a #State variable in the restaurant data view (selectedOption). It all works great until the view is reloaded. Either by going to a different view or relaunching the app. It appears the selectedOption variable in my API call in the onAppear function of the restaurant data view is being reset to what I've set the default value to when I defined the #State variable. I am wondering if there is a way to persist the value of what was chosen in the filter view through view reloads of the restaurant data view.
Restaurant data view:
import SwiftUI
import SwiftyJSON
import SDWebImageSwiftUI
struct RestaurantsView: View {
#EnvironmentObject var locationViewModel: LocationViewModel
#EnvironmentObject var venueDataViewModel: VenueDataViewModel
#State var selectedOption: String = "rating"
#State private var showingSheet = false
#State private var searchText = ""
#State private var showCancelButton: Bool = false
var body: some View {
let CPLatitude: Double = locationViewModel.lastSeenLocation?.coordinate.latitude ?? 0.00
let CPLongitude: Double = locationViewModel.lastSeenLocation?.coordinate.longitude ?? 0.00
GeometryReader { geometry in
VStack {
Text("Restaurants")
.padding()
List(venueDataViewModel.venuesListTen) { index in
NavigationLink(destination: RestaurantsDetailView(venue: index)) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text(index.name ?? "")
.font(.body)
.lineLimit(2)
Text(index.address ?? "")
.font(.subheadline)
.lineLimit(2)
}
}
}
Spacer()
if index.image != nil {
WebImage(url: URL(string: index.image ?? ""), options: .highPriority, context: nil)
.resizable()
.frame(width: 70, height: 70, alignment: .center)
.aspectRatio(contentMode: .fill)
.cornerRadius(12)
}
}
Text("Selected: \(selectedOption)")
Spacer()
}.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
showingSheet.toggle()
}, label: {
Image(systemName: "line.horizontal.3")
.imageScale(.large)
}
)
.sheet(isPresented: $showingSheet, content: {
NavigationView {
RestaurantsFilterView(selectedOption: self.$selectedOption)
}
})
}
}
.onAppear {
venueDataViewModel.retrieveVenues(latitude: CPLatitude, longitude: CPLongitude, category: "restaurants", limit: 50, sortBy: selectedOption, locale: "en_US") { (response, error) in
if let error = error {
print("\(error)")
}
}
}
}
}
}
Filter view:
import SwiftUI
struct RestaurantsFilterView: View {
#EnvironmentObject var locationViewModel: LocationViewModel
#EnvironmentObject var venueDataViewModel: VenueDataViewModel
var sortOptions = ["rating", "review_count"]
#Binding var selectedOption: String
var body: some View {
let CPLatitude: Double = locationViewModel.lastSeenLocation?.coordinate.latitude ?? 0.00
let CPLongitude: Double = locationViewModel.lastSeenLocation?.coordinate.longitude ?? 0.00
VStack {
Text("Filter")
Form {
Section {
Picker(selection: $selectedOption, label: Text("Sort")) {
ForEach(sortOptions, id: \.self) {
Text($0)
}
}.onChange(of: selectedOption) { Equatable in
venueDataViewModel.retrieveVenues(latitude: CPLatitude, longitude: CPLongitude, category: "restaurants", limit: 50, sortBy: selectedOption, locale: "en_US") { (response, error) in
if let error = error {
print("\(error)")
}
}
}
}
}
}
}
}
I am still new to Swift and SwiftUI so I appreciate the help.
Thank you!

Related

Show a sheet in response to a drop

I'm implementing drag and drop, and have a case where I need the user to decide what to do in response to a drop. So I want to bring up a sheet to ask the user for input. The problem is that the sheet doesn't appear until I drag another item to the same view. This does make sense, so I'm looking for a way to handle this differently.
The current approach looks like this (simplified):
struct SymbolInfo {
enum SymbolType {
case string, systemName
}
var type: SymbolType
var string: String
}
struct MyView: View, DropDelegate {
#State var sheetPresented = false
#State var droppedText = ""
static let dropTypes = [UTType.utf8PlainText]
var textColor = NSColor.white
private var frameRect: CGRect = .null
private var contentPath: Path = Path()
private var textRect: CGRect = .null
#State private var displayOutput: SymbolInfo
#State private var editPopoverIsPresented = false
// There's an init to set up the display output, the various rects and path
var body: some View {
ZStack(alignment: stackAlignment) {
BackgroundView() // Draws an appropriate background
.frame(width: frameRect.width, height: frameRect.height)
if displayOutput.type == .string {
Text(displayOutput.string)
.frame(width: textRect.width, height: textRect.height, alignment: .center)
.foregroundColor(textColour)
.font(displayFont)
.allowsTightening(true)
.lineLimit(2)
.minimumScaleFactor(0.5)
}
else {
Image(systemName: displayOutput.string)
.frame(width: textRect.width, height: textRect.height, alignment: .center)
.foregroundColor(textColour)
.minimumScaleFactor(0.5)
}
}
.onAppear {
// Retrieve state information from the environment
}
.focusable(false)
.allowsHitTesting(true)
.contentShape(contentPath)
.onHover { entered in
// Populates an inspector
}
.onTapGesture(count: 2) {
// Handle a double click
}
.onTapGesture(count: 1) {
// Handle a single click
}
.popover(isPresented: $editPopoverIsPresented) {
// Handles a popover for editing data
}
.onDrop(of: dropTypes, delegate: self)
.sheet(sheetPresented: $sheetPresented, onDismiss: sheetReturn) {
// sheet to ask for the user's input
}
}
func sheetReturn() {
// act on the user's input
}
func performDrop(info: DropInfo) -> Bool {
if let item = info.itemProviders(for: dropTypes).first {
item.loadItem(forTypeIdentifier: UTType.utf8PlainText.identifier, options: nil) { (textData, error) in
if let textData = String(data: textData as! Data, encoding: .utf8) {
if (my condition) {
sheetIsPresented = true
droppedText = textData
}
else {
// handle regular drop
}
}
}
return true
}
return false
}
}
So my reasoning is that the drop sets sheetPresented to true, but then it doesn't get acted on until the view is rebuilt, such as on dragging something else to it. But I'm still new to SwiftUI, so I may be incorrect.
Is there a way to handle this kind of interaction that I haven't found?
I never was able to exactly reproduce the problem, but the issue related to trying to have more than one kind of sheet that could be shown, depending on conditions. The solution was to break up the original view into a family of views that encapsulated the different behaviours, and show the appropriate one rather than try to make one view do everything.
I won't show the whole code, since it's too deeply embedded in the app, but here's a demo app that works correctly:
import SwiftUI
import UniformTypeIdentifiers
#main
struct DragAndDropSheetApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
HStack() {
TargetView(viewType: .normal, viewText: "A")
.frame(width: 40, height: 40, alignment: .top)
TargetView(viewType: .protected, viewText: "B")
.frame(width: 40, height: 40, alignment: .top)
TargetView(viewType: .normal, viewText: "C")
.frame(width: 40, height: 40, alignment: .top)
TargetView(viewType: .protected, viewText: "D")
.frame(width: 40, height: 40, alignment: .top)
}
.padding()
}
}
enum ViewType {
case normal, protected
}
struct TargetView: View, DropDelegate {
#State private var sheetPresented = false
#State var viewType: ViewType
#State var viewText: String
#State private var dropText = ""
#State private var dropType: DropActions = .none
static let dropTypes = [UTType.utf8PlainText]
var body: some View {
ZStack(alignment: .center) {
Rectangle()
.foregroundColor(viewType == .normal ? .blue : .red)
Text(viewText)
.foregroundColor(.white)
.frame(width: nil, height: nil, alignment: .center)
}
.focusable(false)
.allowsHitTesting(true)
.onDrop(of: TargetView.dropTypes, delegate: self)
.sheet(isPresented: $sheetPresented, onDismiss: handleSheetReturn) {
ProtectedDrop(isPresented: $sheetPresented, action: $dropType)
}
}
func handleSheetReturn() {
switch dropType {
case .append:
viewText += dropText
case .replace:
viewText = dropText
case .none:
// Nothing to do
return
}
}
func performDrop(info: DropInfo) -> Bool {
if let item = info.itemProviders(for: TargetView.dropTypes).first {
item.loadItem(forTypeIdentifier: UTType.utf8PlainText.identifier, options: nil) { textData, error in
if let textData = String(data: textData as! Data, encoding: .utf8) {
if viewType == .normal {
viewText = textData
}
else {
dropText = textData
sheetPresented = true
}
}
}
return true
}
return false
}
}
enum DropActions: Hashable {
case append, replace, none
}
struct ProtectedDrop: View {
#Binding var isPresented: Bool
#Binding var action: DropActions
var body: some View {
VStack() {
Text("This view is protected. What do you want to do?")
Picker("", selection: $action) {
Text("Append the dropped text")
.tag(DropActions.append)
Text("Replace the text")
.tag(DropActions.replace)
}
.pickerStyle(.radioGroup)
HStack() {
Spacer()
Button("Cancel") {
action = .none
isPresented.toggle()
}
.keyboardShortcut(.cancelAction)
Button("OK") {
isPresented.toggle()
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
}
}

SwiftUI Animation from #Published property changing from outside the View

SwiftUI offers .animation() on bindings that will animate changes in the view. But if an #Published property from an #ObserveredObject changes 'autonomously' (e.g., from a timer), while the view will update in response to the change, there is no obvious way to get the view to animate the change.
In the example below, when isOn is changed from the Toggle, it animates, but when changed from the Timer it does not. Interestingly, if I use a ternary conditional here rather than if/else even the toggle will not trigger animation.
struct ContentView: View {
#ObservedObject var model: Model
var body: some View {
VStack {
if model.isOn {
MyImage(color: .blue)
} else {
MyImage(color: .clear)
}
Spacer()
Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
Spacer()
}
}
}
struct MyImage: View {
var color: Color
var body: some View {
Image(systemName: "pencil.circle.fill")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(color)
}
}
class Model: ObservableObject {
#Published var isOn: Bool = false
var timer = Timer()
init() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [unowned self] _ in
isOn.toggle()
})
}
}
How can I trigger animations when the value changes are not coming from a binding?
The easiest option is to add a withAnimation block inside your timer closure:
withAnimation(.easeIn(duration: 0.5)) {
isOn.toggle()
}
If you don't have the ability to change the #ObservableObject closure, you could add a local variable to mirror the changes:
struct ContentView: View {
#ObservedObject var model: Model
#State var localIsOn = false
var body: some View {
VStack {
if localIsOn {
MyImage(color: .blue)
} else {
MyImage(color: .clear)
}
Spacer()
Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
Spacer()
}.onChange(of: model.isOn) { (on) in
withAnimation {
localIsOn = on
}
}
}
}
You could also do a similar trick with a mirrored variable inside your ObservableObject:
struct ContentView: View {
#ObservedObject var model: Model
var body: some View {
VStack {
if model.animatedOn {
MyImage(color: .blue)
} else {
MyImage(color: .clear)
}
Spacer()
Toggle("switch", isOn: $model.isOn.animation(.easeIn(duration: 0.5)))
Spacer()
}
}
}
class Model: ObservableObject {
#Published var isOn: Bool = false
#Published var animatedOn : Bool = false
var cancellable : AnyCancellable?
var timer = Timer()
init() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [unowned self] _ in
isOn.toggle()
})
cancellable = $isOn.sink(receiveValue: { (on) in
withAnimation {
self.animatedOn = on
}
})
}
}
You can use an implicit animation for that, i.e. .animation(_:value:), e.g.
struct ContentView: View {
#ObservedObject var model: Model
var body: some View {
VStack {
Group {
if model.isOn {
MyImage(color: .blue)
} else {
MyImage(color: .clear)
}
}
.animation(Animation.default, value: model.isOn)
}
}
}
withAnimation is called explicit.

why is a passed parameter displaying the previous content of an EnvironmentObject

this is a Macos app where the parsclass is setup in a previous view that contains the YardageRowView below. That previous view is responsible for changing the contents of the parsclass. This is working is other views that use a NavigationLink to display the views.
When the parsclass is changed, this view is refreshed, but the previous value is put in the text field on the holeValueTestView.
I cannot comprehend how the value is not being passed into the holeValueTestView correctly
This is a view shown as a .sheet, and if I dismiss it and display it again, everything is fine.
if you create a macOS project called YardageSample and replace the ContentView.swift and YardageSampleApp.swift with the two files below, you can see that the display in red changes and the black numbers do not change until you click Done and redisplay the .sheet
//
// YardageSampleApp.swift
// YardageSample
//
// Created by Brian Quick on 2021-04-12.
//
import SwiftUI
#main
struct YardageSampleApp: App {
#StateObject var parsclass = parsClass()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(parsclass)
}
}
}
//
// ContentView.swift
// YardageSample
//
// Created by Brian Quick on 2021-04-12.
//
import SwiftUI
struct ContentView: View {
#StateObject var parsclass = parsClass()
enum ActiveSheet : String , Identifiable {
case CourseMaintenance
var id: String {
return self.rawValue
}
}
#State var activeSheet : ActiveSheet? = nil
var body: some View {
Button(action: {
self.activeSheet = .CourseMaintenance
}) {
Text("Course Maintenance")
}
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .CourseMaintenance:
CourseMaintenance()
}
}.frame(width: 200, height: 200, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
}
}
class parsClass: ObservableObject {
#Published var pars = [parsRec]()
init() {
self.pars = [parsRec]()
self.pars.append(parsRec())
}
func create(newpars: [parsRec]) {
pars.removeAll()
pars = newpars
}
}
class parsRec: Identifiable, Codable {
var id = UUID()
var Hole = 1
var Yardage = 1
}
struct CourseMaintenance: View {
#EnvironmentObject var parsclass: parsClass
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Button(action: {presentationMode.wrappedValue.dismiss()}, label: {
Text("Done")
})
Button(action: {switchScores(number: 1)}, label: {
Text("Button 1")
})
Button(action: {switchScores(number: 2)}, label: {
Text("Button 2")
})
Button(action: {switchScores(number: 3)}, label: {
Text("Button 3")
})
CourseDetail().environmentObject(parsclass)
}.frame(width: 400, height: 400, alignment: .center)
}
func switchScores(number: Int) {
var newparRecs = [parsRec]()
for i in 0..<17 {
let myrec = parsRec()
myrec.Hole = i
myrec.Yardage = number
newparRecs.append(myrec)
}
parsclass.create(newpars: newparRecs)
}
}
struct CourseDetail: View {
#EnvironmentObject var parsclass: parsClass
var body: some View {
HStack(spacing: 0) {
ForEach(parsclass.pars.indices, id: \.self) { indice in
// this displays the previous value
holeValueTestView(value: String(parsclass.pars[indice].Yardage))
// this displays the correct value after parsclass has changed
Text(String(parsclass.pars[indice].Yardage))
.foregroundColor(.red)
}
}
}
}
struct holeValueTestView: View {
#State var value: String
var body: some View {
//TextField(String(value), text: $value)
Text(value)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
There are a couple of issues going on:
You have multiple instances of parsClass. One is defined in YardageSampleApp and passed into the view hierarchy as a #EnvironmentObject. The second is defined in ContentView as a #StateObject. Make sure you're only using one.
On holeValueTestView, you defined value as a #State variable. That gets set initially when the view is created by its parent and then it maintains its own state. So, when the environmentObject changed, because it was in charge of its own state at this point, it didn't update the value. You can simply remove #State and see the behavior that you want.
struct ContentView: View {
#EnvironmentObject var parsclass : parsClass //<-- Here
enum ActiveSheet : String , Identifiable {
case CourseMaintenance
var id: String {
return self.rawValue
}
}
#State var activeSheet : ActiveSheet? = nil
var body: some View {
Button(action: {
self.activeSheet = .CourseMaintenance
}) {
Text("Course Maintenance")
}
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .CourseMaintenance:
CourseMaintenance()
}
}.frame(width: 200, height: 200, alignment: .center)
}
}
struct holeValueTestView: View {
var value: String //<-- Here
var body: some View {
Text(value)
}
}
As a side note:
In Swift, normally type names are capitalized. If you want to write idiomatic Swift, you would change your parsClass to ParsClass for example.

#State var doesn't update when changing the value outside the view

I need to declare the checkBox array using "#State" if I want to use it inside the view using $checkBox and it works fine but when I want to update the toggles (1 or more elements of the array) in a function outside the view, the array is not updated. I tried to declare it using #Binding and #Published but without success. I saw many similar Q&A but I didn't find a solution for my case. This is my code:
struct CheckboxStyle: ToggleStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return HStack {
Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(configuration.isOn ? .green : .gray)
.onTapGesture { configuration.isOn.toggle() }
configuration.label
}
}
}
struct ContentView: View {
#State var checkBox = Array(repeating: false, count: 14)
let checkBoxName: [LocalizedStringKey] = ["checkPressure", "checkVisibility", "checkCloudCover", "checkAirTemp", "checkWaterTemp", "checkWindDir", "checkWindSpeed", "checkWindGust", "checkCurrentDir", "checkCurrentSpeed", "checkSwellDir", "checkWaveHeight", "checkWavePeriod", "checkTideHeight"]
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Group {
ForEach(0..<7) { t in
Toggle(isOn: $checkBox[t], label: {
Text(checkBoxName[t]).font(.footnote).fontWeight(.light)
}).toggleStyle(CheckboxStyle()).padding(5)
}
}
Group {
ForEach(7..<14) { t in
Toggle(isOn: $checkBox[t], label: {
Text(checkBoxName[t]).font(.footnote).fontWeight(.light)
}).toggleStyle(CheckboxStyle()).padding(5)
}
}
}
}
}
}
Thx for help

SwiftUI matchedGeometry + LazyVStack = crash

It took me hours to construct this example, and I'm not sure if I am doing something wrong or there is a bug crashing the app when using matchedGeometry + LazyVStack.
In the video below the app crashed when I click on third rectangle (which was not visible when the app started). Crash disappears if I replace LazyVStack with VStack, but obviously I want to lazy load my things.
Xcode version: Version 12.0.1 (12A7300)
struct ContentView: View {
#Namespace var namespace
#State var selected: Int?
var body: some View {
ZStack {
VStack {
Text("Cool rectangles")
if selected == nil {
ScrollView(.vertical, showsIndicators: false) {
BoxList(namespace: namespace, selected: $selected)
}
}
}
if let id = selected {
Rectangle()
.foregroundColor(.red)
.matchedGeometryEffect(id: id, in: namespace)
.onTapGesture {
withAnimation{
selected = nil
}
}
}
}
}
}
struct BoxList: View {
let namespace: Namespace.ID
#Binding var selected: Int?
var body: some View {
LazyVStack {
ForEach(0..<10){ item in
Rectangle()
.matchedGeometryEffect(id: item, in: namespace)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation {
selected = item
}
}
}
}
}
}
The problem is that you destroy ScrollView breaking matched layout.
Here is fixed variant. Tested with Xcode 12 / iOS 14
struct ContentView: View {
#Namespace var namespace
#State var selected: Int?
var body: some View {
ZStack {
VStack {
Text("Cool rectangles")
ScrollView(.vertical, showsIndicators: false) {
BoxList(namespace: namespace, selected: $selected)
}.opacity(selected == nil ? 1 : 0)
} // << or place here opacity modifier here
if let id = selected {
Rectangle()
.foregroundColor(.red)
.matchedGeometryEffect(id: id, in: namespace)
.onTapGesture {
withAnimation{
selected = nil
}
}
}
}
}
}
struct BoxList: View {
let namespace: Namespace.ID
#Binding var selected: Int?
var body: some View {
LazyVStack {
ForEach(0..<10){ item in
if item == selected {
Color.clear // placeholder to avoid duplicate match id run-time warning
.frame(width: 200, height: 200)
} else {
Rectangle()
.matchedGeometryEffect(id: item, in: namespace)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation {
selected = item
}
}
}
}
}
}
}

Resources