5
\$\begingroup\$

I have an array of date objects, which I want to group by year and month. The data structure I have chosen for my target is an array of arrays of Date objects.

Two Date objects should be in the same array if year AND month match up.

I'm supplying two extensions I added to Date and Formatter to easily extract year and month of a given date, as well as creating date objects more easily.

import Foundation

extension Formatter {

    static let year: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy"
        return formatter
    }()

    static let monthString: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "LLL"
        return formatter
    }()

    static let monthInt: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "LL"
        return formatter
    }()
}


extension Date {

    var monthString: String  { return Formatter.monthString.string(from: self) }
    var monthInt: Int { return Int(Formatter.monthInt.string (from:self))! }
    var yearInt: Int { return Int(Formatter.year.string(from: self))!}

    static func from(year: Int, month: Int, day: Int) -> Date {
        let gregorianCalendar = NSCalendar(calendarIdentifier: .gregorian)!

        var dateComponents = DateComponents()
        dateComponents.year = year
        dateComponents.month = month
        dateComponents.day = day

        let date:Date = gregorianCalendar.date(from: dateComponents)!
        return date
    }

    static func parse(_ string: String, format: String = "yyyy-MM-dd") -> Date {
        let dateFormatter = DateFormatter()
        dateFormatter.timeZone = NSTimeZone.default
        dateFormatter.dateFormat = format

        let date = dateFormatter.date(from: string)!
        return date
    }
}

I then created a helper class with a static method to encapsulate the functionality:

class DateArrayConversionHelper {

    static func sortDateByMonth(dateArray:[Date]) -> [[Date]] {

        //create a copy of input array and sort it

        var inputArray:[Date] = dateArray
        inputArray.sort()

        //create target data structure

        var resultArray:[[Date]] = [[]]

        //set initial variable and add it to target data structure

        resultArray[0].append(inputArray[0])
        var k = 0

        for i in 1 ..< (inputArray.count) {

            if (inputArray[i].yearInt == inputArray[i-1].yearInt)
                && (inputArray[i].monthInt == inputArray[i-1].monthInt) {
                    resultArray[k].append(inputArray[i])
                }
            else {
                    k = k+1
                    resultArray.append([])
                    resultArray[k].append(inputArray[i])
                }
        }

    return resultArray
    }
}

For testing:

let randomDates:[Date] = [Date.parse("2014-05-20"), Date.parse("2012-07-21"), Date.parse("2012-07-01"), Date.parse("2017-01-24"), Date.parse("2017-01-11"), Date.parse("2017-01-14"), Date.parse("2000-01-02"), Date.parse("2000-05-20")]

let resultData:[[Date]] = DateArrayConversionHelper.sortDateByMonth(dateArray: randomDates)

I'm looking for ways to improve my algorithm. I'm also still pretty new to Swift so if you're wondering why I didn't do/use x, it's usually because I didn't know better.

My end game is to use these arrays as a source for a UITableView.

\$\endgroup\$
  • \$\begingroup\$ Why not use Dictionary.init(groupingBy:)? It'll simplify all this so drastically. \$\endgroup\$ – Alexander Apr 25 '18 at 19:43
  • \$\begingroup\$ let date:Date = gregorianCalendar.date(from: dateComponents)!; return date We can clearly see a Date is being returned, and the name date doesn't provide any information. It's meaningless noise. Just return gregorianCalendar.date(from: dateComponents)! \$\endgroup\$ – Alexander Apr 25 '18 at 19:45
4
\$\begingroup\$

I'll focus first on

static func sortDateByMonth(dateArray:[Date]) -> [[Date]]

because that contains the main logic, then mention some other points, and finally present an alternative solution.


  • Your code seems to work correctly (as far as I can tell), with one exception: Apparently it is assumed that the given array is not empty, and it will crash at

    resultArray[0].append(inputArray[0])
    

    otherwise. Adding

    if dateArray.isEmpty {
        return []
    }
    

    right at the top of the function would solve that problem.

  • In

    var inputArray:[Date] = dateArray
    inputArray.sort()
    

    the type annotation is not needed because the type can be inferred automatically:

    var inputArray = dateArray
    inputArray.sort()
    

    But this can be shortened to

    let inputArray = dateArray.sorted()
    
  • Similarly as above,

    var resultArray:[[Date]] = [[]]
    resultArray[0].append(inputArray[0])
    

    can be combined to

    var resultArray = [[inputArray[0]]]
    
  • In

    for i in 1 ..< (inputArray.count) { ... }
    

    the parentheses on the right are not needed. But I prefer

    for i in inputArray.indices.dropFirst() { ... }
    

    because that works for other collection types as well, where the index is not necessarily zero-based (such as ArraySlice).

  • Inside the loop, the statement

            resultArray[k].append(inputArray[i])
    

    appears twice, this can be improved to

    for i in inputArray.indices.dropFirst() {
        if inputArray[i].yearInt != inputArray[i-1].yearInt
            || inputArray[i].monthInt != inputArray[i-1].monthInt {
            resultArray.append([])
            k += 1
        }
        resultArray[k].append(inputArray[i])
    }
    
  • Accessing the count of an array is a O(1) operation, therefore one can get even get rid of the variable k:

    for i in inputArray.indices.dropFirst() {
        if inputArray[i].yearInt != inputArray[i-1].yearInt
            || inputArray[i].monthInt != inputArray[i-1].monthInt {
            resultArray.append([]) // Start new row
        }
        resultArray[resultArray.count - 1].append(inputArray[i])
    }
    

Now let's try to make the date comparison simpler and more efficient. Your code requires 4 calls to a format parsing method in each iteration. Generally, if you are interested in the numerical values of the date components (such as year and month number) then using

let yearAndMonth = calendar.dateComponents([.year, .month], from: inputArray[i])

is more efficient, it computes both numbers in a single call and gives you integers instead of strings.

But here we can do even better: There is a dedicated Calendar method to check if two dates are equal down to some specified component:

calendar.isDate(date1, equalTo: date2, toGranularity: .month)

so that the loop becomes

    let calendar = Calendar(identifier: .gregorian)
    for i in inputArray.indices.dropFirst() {
        if !calendar.isDate(inputArray[i-1], equalTo: inputArray[i], toGranularity: .month) {
            resultArray.append([]) // Start new row
        }
        resultArray[resultArray.count - 1].append(inputArray[i])
    }

This makes your extension Formatter and some of the extension Date properties obsolete, at least for this application.

Note also that I used the Swift Calendar type instead of the Foundation NSCalendar type.

One can even get rid of the index i in the above loop by iterating over the inputArray and a shifted version of that array, and then the complete function looks like this:

static func sortDateByMonth(dateArray:[Date]) -> [[Date]] {

    if dateArray.isEmpty {
        return []
    }

    let inputArray = dateArray.sorted()
    var resultArray = [[inputArray[0]]]

    let calendar = Calendar(identifier: .gregorian)
    for (prevDate, nextDate) in zip(inputArray, inputArray.dropFirst()) {
        if !calendar.isDate(prevDate, equalTo: nextDate, toGranularity: .month) {
            resultArray.append([]) // Start new row
        }
        resultArray[resultArray.count - 1].append(nextDate)
    }
    return resultArray
}

Now some additional remarks:

  • Setting the timezone to NSTimeZone.default is not needed because that is the default. On the other hand, it is a good idea to set the locale to some well-defined value, such as "en_US_POSIX", because otherwise the formatter behavior can depend on the user's regional settings (see Technical Q&A QA1480 – NSDateFormatter and Internet Dates):

    extension Date {
    
        static func parse(_ string: String, format: String = "yyyy-MM-dd") -> Date {
            let dateFormatter = DateFormatter()
            dateFormatter.locale = Locale(identifier: "en_US_POSIX")
            dateFormatter.dateFormat = format
            return dateFormatter.date(from: string)!
        }
    }
    

    Of course this will crash if the string does not match the provided date format.

  • An alternative is to define a (failable) initializer method:

    extension Date {
    
        init?(_ string: String, format: String = "yyyy-MM-dd") {
            let dateFormatter = DateFormatter()
            dateFormatter.locale = Locale(identifier: "en_US_POSIX")
            dateFormatter.dateFormat = format
            guard let date = dateFormatter.date(from: string) else { return nil }
            self = date
        }
    }
    

    which can be used as

    let date = Date("2014-05-20")
    

    and the caller decides whether to force-unwrap the result, or to safely unwrap it with optional binding.

  • Your

    class DateArrayConversionHelper
    

    is apparently used to provide a namespace for the static helper function. If that is the only purpose and no instances of that type are ever created, then a struct with a private init method:

    struct DateArrayConversionHelper {
        private init() {}
    
        static func sortDateByMonth(dateArray:[Date]) -> [[Date]] { ... }
        // ... other helper methods ...
    }
    

    or a caseless enum

    enum DateArrayConversionHelper {
    
        static func sortDateByMonth(dateArray:[Date]) -> [[Date]] { ... }
        // ... other helper methods ...
    }
    

    are good alternatives, compare for example Swift constants: Struct or Enum on Stack Overflow.


Finally an alternative approach, which might be simpler for the usage as a table view data source.

The Dictionary(grouping:by:) initializer (introduces in Swift 4)

creates a new dictionary whose keys are the groupings returned by the given closure and whose values are arrays of the elements that returned each key.

and can in your case be used as

let calendar = Calendar(identifier: .gregorian)
let groupedDates = Dictionary(grouping: dateArray.sorted()) { (date) in
    return calendar.dateInterval(of: .month, for: date)?.start ?? Date.distantPast
}

This returns a dictionary of type [Date : [Date]] where each key is a date representing a start of the month, and the corresponding values are the dates in that month.

Then

let availableMonths = groupedDates.keys.sorted()

is an array of dates, each date representing the start of one month, this can be used to compute the number of sections and the section titles.

For each month,

let datesInMonth = groupedDates[month]

is an array of dates in that month, this can be used to compute the number of rows in a section and for the tableView(_:cellForRowAt:) method.

\$\endgroup\$
  • 1
    \$\begingroup\$ Great explanation and what a detailed discription for the methods. Thanks for this detailed explanation. \$\endgroup\$ – The iOSDev Jan 19 at 6:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.