swiftuiswiftui-listswiftui-viewswiftui-textswiftui-button

How to set up Textfield and Button in Custom SwiftUI View


I am attempting the configure the text field and button in my openweathermap app to be in its own view other than the main content view. In TextFieldView, the action of the button is set up to call an API response. Then, the weather data from the response is populated on a sheet-based DetailView, which is triggered by the button in TextFieldView. I configured the ForEach method in the sheet to return the last city added to the WeatherModel array (which would technically be the most recent city entered into the text field), then populate the sheet-based DetailView with weather data for that city. Previously, When I had the HStack containing the text field, button, and sheet control set up in the ContentView, the Sheet would properly display weather for the city that had just entered into the text field. After moving those items to a separate TextFieldView, the ForEach method appears to have stopped working. Instead, the weather info returned after entering a city name into the text field is displayed on the wrong count. For instance, if I were to enter "London" in the text field, the DetailView in the sheet is completely blank. If I then enter "Rome" as the next entry, the DetailView in the sheet shows weather info for the previous "London" entry. Entering "Paris" in the textfield displays weather info for "Rome", and so on...

To summarize, the ForEach method in the sheet stopped working properly after I moved the whole textfield and button feature to a separate view. Any idea why the issue I described is happening?

Here is my code:

ContentView

struct ContentView: View {
    // Whenever something in the viewmodel changes, the content view will know to update the UI related elements
    @StateObject var viewModel = WeatherViewModel()
        
    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                List {
                    ForEach(viewModel.cityNameList.reversed()) { city in
                        NavigationLink(destination: DetailView(detail: city), label: {
                            Text(city.name).font(.system(size: 18))
                            Spacer()
                            Text("\(city.main.temp, specifier: "%.0f")°")
                                .font(.system(size: 18))
                        })
                    }
                    .onDelete { indexSet in
                        let reversed = Array(viewModel.cityNameList.reversed())
                        let items = Set(indexSet.map { reversed[$0].id })
                        viewModel.cityNameList.removeAll { items.contains($0.id) }
                    }
                }
                .refreshable {
                    viewModel.updatedAll()
                }
                
                TextFieldView(viewModel: viewModel)
                
            }.navigationBarTitle("Weather", displayMode: .inline)
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

TextFieldView

struct TextFieldView: View {
    
    @State private var cityName = ""
    @State private var showingDetail = false
    @FocusState var isInputActive: Bool
    
    var viewModel: WeatherViewModel
    
    var body: some View {
        HStack {
            TextField("Enter City Name", text: $cityName)
                .focused($isInputActive)
            Spacer()
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Button("Done") {
                        isInputActive = false
                    }
                }
            }
            if isInputActive == false {
                Button(action: {
                    viewModel.fetchWeather(for: cityName)
                    cityName = ""
                    self.showingDetail.toggle()
                }) {
                    Image(systemName: "plus")
                        .font(.largeTitle)
                        .frame(width: 75, height: 75)
                        .foregroundColor(Color.white)
                        .background(Color(.systemBlue))
                        .clipShape(Circle())
                }
                .sheet(isPresented: $showingDetail) {
                    ForEach(0..<viewModel.cityNameList.count, id: \.self) { city in
                        if (city == viewModel.cityNameList.count-1) {
                            DetailView(detail: viewModel.cityNameList[city])
                        }
                    }
                }
            }
        }
        .frame(minWidth: 100, idealWidth: 150, maxWidth: 500, minHeight: 30, idealHeight: 40, maxHeight: 50, alignment: .leading)
            .padding(.leading, 16)
            .padding(.trailing, 16)
    }
}

struct TextFieldView_Previews: PreviewProvider {
    static var previews: some View {
        TextFieldView(viewModel: WeatherViewModel())
    }
}

DetailView

struct DetailView: View {
    
    @State private var cityName = ""
    @State var selection: Int? = nil
    
    var detail: WeatherModel
        
    var body: some View {
        VStack(spacing: 20) {
            Text(detail.name)
                .font(.system(size: 32))
            Text("\(detail.main.temp, specifier: "%.0f")&deg;")
                .font(.system(size: 44))
            Text(detail.firstWeatherInfo())
                .font(.system(size: 24))
        }
    }
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        DetailView(detail: WeatherModel.init())
    }
}

ViewModel

class WeatherViewModel: ObservableObject {
    
    @Published var cityNameList = [WeatherModel]()

    func fetchWeather(for cityName: String) {
        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName.escaped())&units=imperial&appid=<YourAPIKey>") else { return }

        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            guard let data = data, error == nil else { return }
            do {
                let model = try JSONDecoder().decode(WeatherModel.self, from: data)
                DispatchQueue.main.async {
                    self.addToList(model)
                }
            }
            catch {
                print(error)
            }
        }
        task.resume()
    }
        
    func updatedAll() {
        // keep a copy of all the cities names
        let listOfNames = cityNameList.map{$0.name}
        // fetch the up-to-date weather info
        for city in listOfNames {
            fetchWeather(for: city)
        }
    }
    
    func addToList( _ city: WeatherModel) {
        // if already have this city, just update
        if let ndx = cityNameList.firstIndex(where: {$0.name == city.name}) {
            cityNameList[ndx].main = city.main
            cityNameList[ndx].weather = city.weather
        } else {
            // add a new city
            cityNameList.append(city)
        }
    }
}

Model

struct WeatherModel: Identifiable, Codable {
    let id = UUID()
    var name: String = ""
    var main: CurrentWeather = CurrentWeather()
    var weather: [WeatherInfo] = []
    
    func firstWeatherInfo() -> String {
        return weather.count > 0 ? weather[0].description : ""
    }
}

struct CurrentWeather: Codable {
    var temp: Double = 0.0
    var humidity = 0
}

struct WeatherInfo: Codable {
    var description: String = ""
}

Solution

  • You need to use an ObservedObject in your TextFieldView to use your original (single source of truth) @StateObject var viewModel that you create in ContentView and observe any change to it.

    So use this:

    struct TextFieldView: View {
        @ObservedObject var viewModel: WeatherViewModel
        ...
    }