UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Nested ForEach Loops or a different solution

Forums > SwiftUI

Hi. I am working on an app the gets a JSON file containing festival shows that have multiple starting times. I am trying to display the show by starting times grouped together and as the starting time approaches, the show moves into the next group of starting times:

This is my JSON model:

struct Festival: Codable {
    let fest: Fest2
}

// MARK: - Fest
struct Fest2: Codable {
    let days, year: String
    let shows: [Shows]
}

// MARK: - Show
struct Shows: Codable, Identifiable {
    let id: Int
    let showName, stageName, description: String
    let time: [Double]
    let isFavorite, oneNight: Bool
}

Here is my code to display the info:

{
                ScrollView {
                    VStack {
                    Text("Upcoming Shows")
                        .font(.largeTitle)
                        .bold()

                    Divider()

                    }

                    ForEach(user.fest.shows.sorted(by: { $0.showName < $1.showName}), id: \.id)  { show in

                        ForEach(show.time, id: \.self) { index in

                            switch getMinutes(date: index) {
                            case 0...15:

                                  VStack {
                                    Text("Show is starting soon")
                                    HStack {
                                        Text(show.showName)
                                        Spacer()
                                        Text(stringToSystemTime(time: index))
                                        Image(systemName: "info.circle")

                                    }
                                    HStack {
                                        Text(show.stageName)
                                            .font(.caption)
                                        Spacer()

                                    }
                                }.padding()

                            case 16...30:
                                VStack {
                                    Text("Shows starting in 30 minutes or less")
                                    HStack {
                                        Text(show.showName)
                                        Spacer()
                                        Text(stringToSystemTime(time: index))
                                        Image(systemName: "info.circle")

                                    }
                                    HStack {
                                        Text(show.stageName)
                                            .font(.caption)
                                        Spacer()

                                    }
                                }.padding()

                            case 30...45:
                                VStack {
                                    Text("Shows starting in 45 minutes or less")
                                    HStack {
                                        Text(show.showName)
                                        Spacer()
                                        Text(stringToSystemTime(time: index))
                                        Image(systemName: "info.circle")

                                    }
                                    HStack {
                                        Text(show.stageName)
                                            .font(.caption)
                                        Spacer()

                                    }
                                }.padding()

                            default:
                                Text("There are no upcoming shows")

                            }

                        }

                    }
                } //: SCROLLVIEW
            }

The function getMinutes calculates the minutes remaining from the starting time of the show based on the current time.

This is a picture of what I am trying to achieve:

https://drive.google.com/file/d/1VzE0eFVSUzml49ADF4N_HV7aX00QruXX/view?usp=sharing

3      

Right off the bat, I would say refactor your View so that you don't have so much repeated code in that loop. Move the switch into a function and do your logic there.

Apart from that, could you post some sample JSON and your getMinutes and stringToSystemTime functions? That would make it so much easier to test possible solutions.

3      

I didn't refactor my view yet as I was trying to get the functioality of it working. It just kept growing and changing. Here is a sample of my JSON file:

{
  "fest" : {
    "days" : "10, 11, 17, 18",
    "year" : "2021",
    "shows" : [
      {
        "id" : 1,
        "showName" : "The Duelist",
        "stageName" : "Queen",
        "description" : "Comedy Sword Play",
        "time" : [
          1619370000,
          1619381700,
          1619387100,
          1619392500
        ],
        "isFavorite" : false,
        "oneNight" : false
      },
      {
        "id" : 2,
        "showName" : "The Bilge Pumps",
        "stageName" : "Queen",
        "description" : "Pirate Comedy Band",
        "time" : [
          1619373600,
          1619401500,
          1619384400,
          1619392500
        ],
        "isFavorite" : false,
        "oneNight" : false
      },
      {
        "id" : 3,
        "showName" : "Whiskey Bay Rovers",
        "stageName" : "Compass Rose Pirate Pub",
        "description" : "Shanty Band",
        "time" : [
          1619373600,
          1619401500,
          1619384400,
          1619389800
        ],
        "isFavorite" : false,
        "oneNight" : false
      },

      {
        "id" : 16,
        "showName" : "Bayou Cirque",
        "stageName" : "Washing Well Stage",
        "description" : "Circus Acrobatics",
        "time" : [
          1619401500
        ],
        "isFavorite" : false,
        "oneNight" : false
      }
    ]
  }
}

Here is the getMinutes function:

func getMinutes(date: Double)->Int {
    let now = Date()

    let showTime = Date(timeIntervalSince1970: date)

    let diffs = Calendar.current.dateComponents([.minute], from: now, to: showTime)

    return  diffs.minute ?? 0
}

And the stringToSystemTime function:

func stringToSystemTime(time: String) -> String {
    let fullDate = Date(timeIntervalSince1970: TimeInterval(Int(time)!))
    let formatter = DateFormatter()
    formatter.dateFormat = "E, d MMM yyyy HH:mm:ss Z"
    formatter.timeStyle = .short

    let time = formatter.string(from: fullDate)

    return time
}

3      

See how this works for you. I've tried to include sufficient comments to explain everything but feel free to ask if anything is still unclear.

import SwiftUI

let festivalJSON = """
{
  "fest" : {
    "days" : "10, 11, 17, 18",
    "year" : "2021",
    "shows" : [
      {
        "id" : 1,
        "showName" : "The Duelist",
        "stageName" : "Queen",
        "description" : "Comedy Sword Play",
        "time" : [
          1619511032,
          1619511812,
          1619512952,
          1619513732,
          1619514632
        ],
        "isFavorite" : false,
        "oneNight" : false
      }
    ]
  }
}
"""

//No changes
struct Festival: Codable {
    let fest: Fest2
}

//No changes
struct Fest2: Codable {
    let days, year: String
    let shows: [Show]
}

struct Show: Codable, Identifiable {
    let id = UUID() //added to help SwiftUI tell shows apart
    let showID: Int //renamed so as to not conflict with above
    let showName, stageName, description: String
    let times: [Double] //renamed to make better sense
    let isFavorite, oneNight: Bool

    //added so we can change the names in our struct
    enum CodingKeys: String, CodingKey {
        case showID = "id"
        case showName, stageName, description
        case times = "time"
        case isFavorite, oneNight
    }
}

//created a custom init to build a Show from an existing Show
// but with a single time
//this is used when we create the showsDict dictionary
extension Show {
    init(from show: Show, at time: Double) {
        showID = show.showID
        showName = show.showName
        stageName = show.stageName
        description = show.description
        times = [time]
        isFavorite = show.isFavorite
        oneNight = show.oneNight
    }
}

//enum to help us sort our shows into sections based
// on time offset
//made some assumptions about terminology and
// what to do with offset < 0 or > 45
enum ShowOffset: Int, CaseIterable {
    case past
    case next15Mins
    case next30Mins
    case next45Mins
    case future

    var label: String {
        switch self {
        case .past: return "Showtime has passed"
        case .next15Mins: return "Shows starting soon"
        case .next30Mins: return "Upcoming shows"
        case .next45Mins: return "Future Shows"
        //had no idea what to call this one...
        case .future: return "Far Future shows"
        }
    }

    static func getOffsetFromMinutes(from minutes: Int) -> ShowOffset {
        switch minutes {
        case ..<0: return .past
        case ...15: return .next15Mins
        case ...30: return .next30Mins
        case ...45: return .next45Mins
        default: return .future
        }
    }
}

struct FestivalView: View {

    //so we don't have to keep typing [ShowOffset:[Show]]
    typealias ShowDict = [ShowOffset:[Show]]

    @State private var festival: Fest2? = nil

    //since it's expensive to create DateFormatters, we
    // don't want to do it every time we call getSystemTimeAsString
    // so we create it once here and store it as a property
    let formatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "E, d MMM yyyy HH:mm:ss Z"
        formatter.timeStyle = .short
        return formatter
    }()

    //component Views
    func sectionHeader(for section: String) -> some View {
        Text(section).bold().textCase(.uppercase)
    }

    func showRow(from show: Show) -> some View {
        VStack {
            HStack {
                Text(show.showName)
                Spacer()
                Text(getSystemTimeAsString(time: show.times[0]))
                Image(systemName: "info.circle")
            }
            HStack {
                Text(show.stageName)
                    .font(.caption)
                Spacer()
            }
        }
    }

    //main View body
    var body: some View {
        ScrollView {
            VStack {
                Text("Upcoming Shows")
                    .font(.largeTitle)
                    .bold()
                Divider()
            }

            let showDict = generateShowsDict()
            ForEach(ShowOffset.allCases, id: \.self) { key in
                if let showList = showDict[key] {
                    Section(header: sectionHeader(for: key.label)) {
                        ForEach(showList) { show in
                            showRow(from: show)
                        }
                        .padding()
                    }
                }
            }
        }
        .onAppear(perform: loadJSON)        
    }

    func loadJSON() {
        //do real loading of your JSON and assign the result to festival
        do {
            let fest = try JSONDecoder().decode(Festival.self, from: festivalJSON.data(using: .utf8)!)
            festival = fest.fest
        } catch {
            print(error)
        }
    }

    func generateShowsDict() -> ShowDict {
        var displayDict: ShowDict = [:]

        if let festival = festival {
            let shows = festival.shows

            //cen be done as nested for loops...
            for show in shows {
                for time in show.times {
                    displayDict[getShowOffset(date: time), default: []].append(Show(from: show, at: time))
                }
            }

            //or functionally with reduce(into:_:)
            //displayDict = shows.reduce(into: [:]) { a, c in
            //    c.times.forEach { time in
            //        a[getShowOffset(date: time), default: []].append(Show(from: c, at: time))
            //    }
            //}

            //AFAIK there is no real difference between the two in performance
        }

        return displayDict
    }

    func getMinutes(date: Double) -> Int {
        let now = Date()
        let showTime = Date(timeIntervalSince1970: date)
        let diffs = Calendar.current.dateComponents([.minute], from: now, to: showTime)
        return  diffs.minute ?? 0
    }

    func getShowOffset(date: Double) -> ShowOffset {
        let minutes = getMinutes(date: date)
        return ShowOffset.getOffsetFromMinutes(from: minutes)
    }

    //changed parameter type because Show.times is [Double]
    func getSystemTimeAsString(time: Double) -> String {
        //don't need to do this:
        //  let fullDate = Date(timeIntervalSince1970: TimeInterval(Int(time)!))
        //because TimeInterval is a typealias for Double and
        // time is already a Double
        let fullDate = Date(timeIntervalSince1970: time)
        return formatter.string(from: fullDate)
    }
}

3      

Thanks! I really appreciate the time and effort you put into helping me out and the comments that you out in the code. It really helps to understand what is going on. This is pretty much what I was looking to do.

Just a couple of things is the JSON data let festivalJSON = """ doesn't not work when put into a seperate file, but only when kept in the same file as FestivalView. As far as the listings by time, I would like to sort them alphabetically in the UPCOMING SHOWS and the FUTURE SHOWS sections, but in the FAR FUTURE SHOWS sorted by the time. I don't know if it would be possible as it is without making a lot of changes.

I don't need to list the past shows once they are past the starting time. So i made the changes here:

static func getOffsetFromMinutes(from minutes: Int) -> ShowOffset {
        switch minutes {
      //  case ..<0: return .past
        case 0...15: return .next15Mins
        case ...30: return .next30Mins
        case ...45: return .next45Mins
        default: return .future
        }
    }

I made changes to the first 2 cases.

In order for this list to refresh and automatically update as the time updates, should I use a timer to do this?

The only other thing I will do is make the Image(systemName: "info.circle") image in the showRow function a NavigationLink so I can display other info about the show, like description, etc.

And your way of loading the JSON data is much simpler than mine. I was using the following code for that but will use yours instead, of course.

extension Bundle {
    func decode<T: Decodable>(_ type: T.Type, from file: String, dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .deferredToDate, keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) -> T {
        guard let url = self.url(forResource: file, withExtension: nil) else {
            fatalError("Failed to locate \(file) in bundle.")
        }

        guard let data = try? Data(contentsOf: url) else {
            fatalError("Failed to load \(file) from bundle.")
        }

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = dateDecodingStrategy
        decoder.keyDecodingStrategy = keyDecodingStrategy

        do {
            return try decoder.decode(T.self, from: data)
        } catch DecodingError.keyNotFound(let key, let context) {
            fatalError("Failed to decode \(file) from bundle due to missing key '\(key.stringValue)' not found – \(context.debugDescription)")
        } catch DecodingError.typeMismatch(_, let context) {
            fatalError("Failed to decode \(file) from bundle due to type mismatch – \(context.debugDescription)")
        } catch DecodingError.valueNotFound(let type, let context) {
            fatalError("Failed to decode \(file) from bundle due to missing \(type) value – \(context.debugDescription)")
        } catch DecodingError.dataCorrupted(_) {
            fatalError("Failed to decode \(file) from bundle because it appears to be invalid JSON")
        } catch {
            fatalError("Failed to decode \(file) from bundle: \(error.localizedDescription)")
        }
    }
}

3      

Quick question: Does this festival data come from an APi somewhere? If so, can you supply a URL to use? Thanks!

3      

No the data does not come from an API. It is currently local only. Debating whether or not to have the JSON file hosted on a server or not. Problem is that wifi and cell internet is poor at this location, so leaning toward keeping it local.

4      

Just a couple of things is the JSON data let festivalJSON = """ doesn't not work when put into a seperate file, but only when kept in the same file as FestivalView.

Doesn't work, how? This static string was just for ease of testing anyway. You shouldn't be relying on it for data. Load a JSON file either from you main bundle or an API call.

As far as the listings by time, I would like to sort them alphabetically in the UPCOMING SHOWS and the FUTURE SHOWS sections, but in the FAR FUTURE SHOWS sorted by the time. I don't know if it would be possible as it is without making a lot of changes.

Not terribly difficult.

In order for this list to refresh and automatically update as the time updates, should I use a timer to do this?

Yes. I used an .id on the ForEach loop so that when the timer fires we can force the View to update by changing the .id. I'm sure there are other ways this could be handled.

The only other thing I will do is make the Image(systemName: "info.circle") image in the showRow function a NavigationLink so I can display other info about the show, like description, etc.

Easy enough. Just wrap everything in a NavigationView and then use a NavigationLink with the Image as its label.

And your way of loading the JSON data is much simpler than mine. I was using the following code for that but will use yours instead, of course.

The way I loaded the JSON is fine for testing with a static string but I wouldn't use it for a real app.

Ultimately, while this all works, if it were me doing this I would refactor the code to use a view model that loads the JSON and vends the show lists.

Something else you might consider looking into is RelativeDateTimeFormatter. That would give you an easy way to turn times into strings like in 15 minutes, in 2 days etc.

At any rate, I uploaded a solution to github so as to not keep posting lengthy code samples.

FestivalApp

3      

Thanks for the update. I probably have misspoken on a couple of things. I was previusly using the JSON Bundle extension to load my JSON file, and after using it as a static string the way you had it originally, I had gone back to using the Bundle extension and forgot to remove let festivalJSON = """ from the data file. That is why it wasn't working.

I already had added the NavigationLink to the showRow function.

I added a new function to be used in the sectionHeader function that will allow me to change the background color of the section name based on the key. This makes the different section stand out. For example, I have the section that is under 15 minutes with the background color in red, and the other sections different colors.

Thank for the info on theRelativeDateTimeFormatter. I will have to look at that later, but it does give my an idea of putting that into the SectionHeader instead of the name. But will work on that later.

Anyways, I appreciate your help very much, and if you have a Ko-Fi account I would like to contribute.

3      

BUILD THE ULTIMATE PORTFOLIO APP Most Swift tutorials help you solve one specific problem, but in my Ultimate Portfolio App series I show you how to get all the best practices into a single app: architecture, testing, performance, accessibility, localization, project organization, and so much more, all while building a SwiftUI app that works on iOS, macOS and watchOS.

Get it on Hacking with Swift+

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.