Last active
August 8, 2018 18:55
-
-
Save KyNorthstar/b31c53cfe30ea3b6e3e6ff71adbf309f to your computer and use it in GitHub Desktop.
Create a datetime format with clear and concise Swift enums instead of a cryptic string
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright Ben Leggiero 2017 BH-1-PS | |
// https://github.com/BlueHuskyStudios/Licenses/blob/master/Licenses/BH-1-PS.txt | |
import Foundation | |
// MARK: Types and functionality | |
private let millisecondsPerSecond: TimeInterval = 1000 | |
private let secondsPerMinute: TimeInterval = 60 | |
private let minutesPerHour: TimeInterval = 60 | |
private let secondsPerHour: TimeInterval = secondsPerMinute * minutesPerHour | |
private let hoursPerDay: TimeInterval = 24 | |
private let secondsPerDay: TimeInterval = secondsPerHour * hoursPerDay | |
private let daysPerWeek: TimeInterval = 7 | |
private let secondsPerWeek: TimeInterval = secondsPerDay * daysPerWeek | |
private let daysPerYear: TimeInterval = 365.24219 | |
private let secondsPerYear: TimeInterval = secondsPerDay * daysPerYear | |
struct DatetimeFormatter { | |
public static let shared = DatetimeFormatter() | |
var calendar: Calendar | |
/// The separator used between two adjacent non-`.separator` datetime pieces | |
var defaultSeparator: String | |
init(calendar: Calendar = .autoupdatingCurrent, defaultSeparator: String = " ") { | |
self.calendar = calendar | |
self.defaultSeparator = defaultSeparator | |
} | |
func string(from date: Date, as format: [DatetimePiece]) -> String { | |
return string(from: date, as: format.map { StyledDatetimePiece(piece: $0, representation: .auto, calendar: self.calendar) }) | |
} | |
func string(from date: Date, as format: [StyledDatetimePiece]) -> String { | |
guard format.count > 0 else { return "" } | |
var separatedPieces = [format[0]] | |
(1..<format.count).forEach { formatIndex in | |
let previousPiece = format[formatIndex - 1] | |
let currentPiece = format[formatIndex] | |
let needsGeneratedSeparator: Bool | |
switch previousPiece.piece { | |
case .separator: | |
needsGeneratedSeparator = false | |
default: | |
switch currentPiece.piece { | |
case .separator: | |
needsGeneratedSeparator = false | |
default: | |
needsGeneratedSeparator = true | |
} | |
} | |
if needsGeneratedSeparator { | |
separatedPieces.append(StyledDatetimePiece(piece: .separator(separatorString: self.defaultSeparator), | |
representation: .auto, | |
calendar: self.calendar)) | |
} | |
separatedPieces.append(currentPiece) | |
} | |
return separatedPieces.reduce("", { (formattedDate, styledDatetimePiece) -> String in | |
return formattedDate + styledDatetimePiece.description(of: date) | |
}) | |
} | |
} | |
struct StyledDatetimePiece { | |
var piece: DatetimePiece | |
var representation: DatetimePieceRepresentation | |
var calendar: Calendar | |
} | |
extension StyledDatetimePiece { | |
func description(of date: Date) -> String { | |
switch piece { | |
case .separator(let separatorString): | |
return separatorString | |
case .millisecondsSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch) * millisecondsPerSecond) | |
case .millisecondOfSecond: | |
return representationString(from: (millisecondsPerSecond * date.timeIntervalSinceReferenceDate).truncatingRemainder(dividingBy: millisecondsPerSecond)) | |
case .secondsSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch)) | |
case .secondOfMinute: | |
let second = calendar.component(.second, from: date) | |
return representationString(from: second) | |
case .minutesSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch) / secondsPerMinute) | |
case .minuteOfHour: | |
let minute = calendar.component(.minute, from: date) | |
return representationString(from: minute) | |
case .hoursSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch) / secondsPerHour) | |
case .hourOfDay(let clockStyle): | |
let hour = calendar.component(.hour, from: date) | |
return representationString(from: hour % Int(clockStyle.hoursPerDay)) | |
case .periodOfHour(let morningMarker, let eveningMarker, let onlyIfUserPrefersTwelveHour): | |
if onlyIfUserPrefersTwelveHour { | |
switch ClockStyle.userPreference { | |
case .twentyFourHour: | |
return "" | |
case .twelveHour: | |
break | |
} | |
} | |
let isMorning = calendar.component(.hour, from: date) < 12 | |
if isMorning { | |
return morningMarker | |
} else { | |
return eveningMarker | |
} | |
case .daysSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch) / secondsPerDay) | |
case .dayOfWeek: | |
let weekday = calendar.component(.weekday, from: date) | |
return representationString(from: weekday) | |
case .dayOfMonth: | |
let day = calendar.component(.day, from: date) | |
return representationString(from: day) | |
// case .dayOfYear: // TODO | |
case .weeksSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch) / secondsPerWeek) | |
case .weekOfMonth(_): // FIXME: use includeFirstPartialWeek | |
let week = calendar.component(.weekOfMonth, from: date) | |
return representationString(from: week) | |
case .weekOfYear(_): // FIXME: includeFirstPartialWeek | |
let week = calendar.component(.weekOfYear, from: date) | |
return representationString(from: week) | |
case .monthOfYear: | |
let month = calendar.component(.month, from: date) | |
return representationString(from: month) | |
case .yearsSince(let epoch): | |
return representationString(from: date.timeIntervalSince(epoch) / secondsPerYear) | |
case .yearOfEra: | |
let year = calendar.component(.year, from: date) | |
return representationString(from: year) | |
} | |
} | |
private func representationString(from raw: TimeInterval) -> String { | |
return representation.string(from: raw, piece: piece) | |
} | |
private func representationString(from raw: Int) -> String { | |
return representationString(from: TimeInterval(raw)) | |
} | |
} | |
enum DatetimePiece { | |
static let defaultMorningMarker = ClockStyle.defaultMorningMarker | |
static let defaultEveningMarker = ClockStyle.defaultEveningMarker | |
case separator(separatorString: String) | |
case millisecondsSince(epoch: Date) | |
case millisecondOfSecond | |
case secondsSince(epoch: Date) | |
case secondOfMinute | |
case minutesSince(epoch: Date) | |
case minuteOfHour | |
case hoursSince(epoch: Date) | |
case hourOfDay(clockStyle: ClockStyle) | |
case periodOfHour(morningMarker: String, eveningMarker: String, onlyIfUserPrefersTwelveHour: Bool) | |
case daysSince(epoch: Date) | |
case dayOfWeek | |
case dayOfMonth | |
// case dayOfYear // TODO | |
case weeksSince(epoch: Date) | |
case weekOfMonth(includeFirstPartialWeek: Bool) | |
case weekOfYear(includeFirstPartialWeek: Bool) | |
case monthOfYear | |
case yearsSince(epoch: Date) | |
case yearOfEra | |
} | |
extension DatetimePiece { | |
/// Used when `.auto` is selected as the representation. If this returns `.auto`, that means to just use its | |
/// native string description. | |
var defaultRepresentation: DatetimePieceRepresentation { | |
switch self { | |
case .separator: | |
return .auto | |
case .millisecondsSince, | |
.secondsSince, | |
.minutesSince, | |
.hoursSince, | |
.daysSince, | |
.weeksSince, | |
.yearsSince: | |
return .integerOnly(minimumDigits: 0) | |
case .millisecondOfSecond: | |
return .integerOnly(minimumDigits: 4) | |
case .secondOfMinute, | |
.minuteOfHour, | |
.hourOfDay, | |
.dayOfMonth, | |
.monthOfYear, | |
.weekOfYear: | |
return .integerOnly(minimumDigits: 2) | |
case .periodOfHour: | |
return .auto | |
case .dayOfWeek, | |
.weekOfMonth, | |
.yearOfEra: | |
return .integerOnly(minimumDigits: 1) | |
// case .dayOfYear: | |
// return .integerOnly(minimumDigits: 0) | |
} | |
} | |
} | |
enum ClockStyle { | |
static let defaultMorningMarker = "AM" | |
static let defaultEveningMarker = "PM" | |
case twelveHour(morningMarker: String, eveningMarker: String) | |
case twentyFourHour | |
} | |
extension ClockStyle { | |
private static var userLocale: Locale { return Locale.current } | |
private static func userPreferredHourMarker(at date: Date) -> String { | |
let dateFormatter = DateFormatter() | |
dateFormatter.dateFormat = "a" | |
return dateFormatter.string(from: date) | |
} | |
private static var userPreferredMorningMarker: String { | |
return userPreferredHourMarker(at: Date(timeIntervalSinceReferenceDate: 60)) | |
} | |
private static var userPreferredEveningMarker: String { | |
return userPreferredHourMarker(at: Date(timeIntervalSinceReferenceDate: 60 * 60 * 13)) | |
} | |
static var userPreference: ClockStyle { | |
let userPreferenceIs12Hour: Bool | |
if let formatFromPreferences = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: NSLocale.current) { | |
// like "h a" for 12-hour and "HH" for 24-hour | |
userPreferenceIs12Hour = formatFromPreferences.contains("a") | |
} else { | |
userPreferenceIs12Hour = false | |
} | |
if userPreferenceIs12Hour { | |
return .twelveHour(morningMarker: userPreferredMorningMarker, eveningMarker: userPreferredEveningMarker) | |
} else { | |
return .twentyFourHour | |
} | |
} | |
var hoursPerDay: UInt { | |
switch self { | |
case .twelveHour: | |
return 12 | |
case .twentyFourHour: | |
return 24 | |
} | |
} | |
} | |
enum DatetimePieceRepresentation { | |
case auto | |
/// Only the integer part of the datetime piece. | |
/// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017 01 10"` | |
/// - Parameter minimumDigits: The smallest number of integer digits to display. `0` signifies that a value of `0` | |
/// results in an empty string. | |
case integerOnly(minimumDigits: UInt) | |
/// The integer and fractional parts of the datetime piece. | |
/// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017.02466 01.2903 10.4873"` | |
/// - Parameters: | |
/// minimumIntegerDigits: The smallest number of integer digits to display. `0` signifies that a value of `0` | |
/// results in an empty string. | |
/// maximumFractionDigits: The largest number of fractional digits to display. `0` results in this behaving | |
/// like `.integersOnly`. | |
case integerAndFractionalDigits(minimumIntegerDigits: UInt, maximumFractionDigits: UInt) | |
/// Only the fractional part of the datetime piece. | |
/// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `".02466 .2903 .4873"` | |
/// maximumFractionDigits: The largest number of fractional digits to display. `0` results in this behaving | |
/// like `.integersOnly`. | |
case onlyFractionalDigits(maximumFractionDigits: UInt) | |
/// The ordinal form of the number, like `1st`, `22nd`, `503rd`, etc. | |
case ordinal | |
// /// The short word used to describe the datetime piece. | |
// /// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017 Jan Ten"` | |
// case shortWord // TODO | |
// | |
// /// The short word used to describe the datetime piece. | |
// /// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017 A.D. January Ten"` | |
// case longWord // TODO | |
/// A custom number formatter for the datetime piece | |
case customFormatted(customFormatter: NumberFormatter) | |
} | |
extension DatetimePieceRepresentation { | |
func string(from timeInterval: TimeInterval, piece: DatetimePiece) -> String { | |
let representation: DatetimePieceRepresentation | |
switch self { | |
case .auto: | |
representation = piece.defaultRepresentation | |
default: | |
representation = self | |
} | |
switch representation { | |
case .auto: | |
return timeInterval.description | |
case .integerOnly(let minimumDigits): | |
let formatter = NumberFormatter() | |
formatter.minimumIntegerDigits = Int(minimumDigits) | |
formatter.maximumFractionDigits = 0 | |
return formatter.string(from: NSNumber(value: timeInterval))! | |
case .integerAndFractionalDigits(let minimumIntegerDigits, let maximumFractionDigits): | |
let formatter = NumberFormatter() | |
formatter.minimumIntegerDigits = Int(minimumIntegerDigits) | |
formatter.maximumFractionDigits = Int(maximumFractionDigits) | |
return formatter.string(from: NSNumber(value: timeInterval))! | |
case .onlyFractionalDigits(let maximumDigits): | |
let formatter = NumberFormatter() | |
formatter.maximumIntegerDigits = 0 | |
formatter.maximumFractionDigits = Int(maximumDigits) | |
return formatter.string(from: NSNumber(value: timeInterval))! | |
case .ordinal: | |
return ordinalFormatter.string(from: NSNumber(value: timeInterval))! | |
// case .shortWord: // TODO | |
// case .longWord: // TODO | |
case .customFormatted(let customFormatter): | |
return customFormatter.string(from: NSNumber(value: timeInterval))! | |
} | |
} | |
private func integerPart(of float: CGFloat, minimumDigits: UInt) -> String { | |
if minimumDigits == 0, float == 0 { | |
return "" | |
} else { | |
return String(format: "%0\(minimumDigits)d", Int(float.rounded())) | |
} | |
} | |
private func fractionalPart(of float: CGFloat, maximumDigits: UInt) -> String { | |
let stringValue = Int(float - float.rounded(.towardZero)).description | |
let maximumDigits = Int(maximumDigits) | |
if stringValue.characters.count > maximumDigits { | |
return stringValue.substring(to: stringValue.index(stringValue.startIndex, offsetBy: maximumDigits)) | |
} else { | |
return stringValue | |
} | |
} | |
} | |
private let ordinalFormatter: NumberFormatter = { | |
let ordinalFormatter = NumberFormatter() | |
ordinalFormatter.numberStyle = .ordinal | |
return ordinalFormatter | |
}() | |
// MARK: - Usage | |
var formatter = DatetimeFormatter.shared | |
print(formatter.string(from: Date(), as: [.yearOfEra, .monthOfYear, .dayOfMonth])) // like "2017 01 19" | |
formatter.defaultSeparator = "-" | |
print(formatter.string(from: Date(), as: [.yearOfEra, .monthOfYear, .dayOfMonth])) // like "2017-01-19" | |
formatter.defaultSeparator = ":" | |
print(formatter.string(from: Date(), as: [.hourOfDay(clockStyle: .userPreference), .minuteOfHour, .secondOfMinute])) // like "14:48:50" or "2:48:50" | |
formatter = DatetimeFormatter() | |
print(formatter.string(from: Date(), as: [.yearOfEra, .separator(separatorString: "-"), | |
.monthOfYear, .separator(separatorString: "-"), | |
.dayOfMonth, | |
.separator(separatorString: " at "), | |
.hourOfDay(clockStyle: .userPreference), .separator(separatorString: ":"), | |
.minuteOfHour, .separator(separatorString: ":"), | |
.secondOfMinute])) // like "2017-01-19 at 14:48:50" or "2017-01-19 at 02:48:50" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment