diff --git a/CHANGELOG.md b/CHANGELOG.md index 87554db..4b765ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,8 @@ The changelog for `Pai`. Also see the [releases](https://github.com/lkmfz/Pai/releases) on GitHub. --------------------------------------- \ No newline at end of file +-------------------------------------- + +## Upcoming Releases + +### [Pre-release 0.1.0](https://github.com/lkmfz/Ubud/releases/tag/0.1.0) \ No newline at end of file diff --git a/Pai-Example/Pai-Example.xcodeproj/project.pbxproj b/Pai-Example/Pai-Example.xcodeproj/project.pbxproj index 99c29a4..589342d 100644 --- a/Pai-Example/Pai-Example.xcodeproj/project.pbxproj +++ b/Pai-Example/Pai-Example.xcodeproj/project.pbxproj @@ -432,9 +432,12 @@ files = ( ); inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-Pai-Example/Pods-Pai-Example-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Pai/Pai.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Pai.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/Pai-Example/Pai-Example/Base.lproj/Main.storyboard b/Pai-Example/Pai-Example/Base.lproj/Main.storyboard index 03c13c2..39c8e6a 100644 --- a/Pai-Example/Pai-Example/Base.lproj/Main.storyboard +++ b/Pai-Example/Pai-Example/Base.lproj/Main.storyboard @@ -1,24 +1,48 @@ - + + + + - + + - + - + + + + + + + + + + + + + + + + + + + + + diff --git a/Pai-Example/Pai-Example/ExampleViewController.swift b/Pai-Example/Pai-Example/ExampleViewController.swift index cd4cefc..52f6b69 100644 --- a/Pai-Example/Pai-Example/ExampleViewController.swift +++ b/Pai-Example/Pai-Example/ExampleViewController.swift @@ -7,10 +7,70 @@ // import UIKit +import Pai -final class ExampleViewController: UIViewController { +final class ExampleViewController: UIViewController, PaiCalendarDelegate, PaiCalendarDataSource { + + private var style: PaiStyle = { + let style = PaiStyle.shared + style.dateItemShouldGreyOutPastDates = true + style.dateItemShouldHideOffsetDates = true + style.dateItemShouldDisplayLine = true + /// Date events configuration + style.dateItemDayLabelInset = UIEdgeInsets(top: 3.0, left: 8.0, bottom: 30.0, right: 8.0) + style.dateItemDisplayEventsIfAny = true + return style + }() + + private lazy var monthlyView: MonthCollectionView = { + let view = MonthCollectionView(style: self.style, backwardsMonths: 24, forwardsMonths: 24, calendarDataSource: self) + view.calendarDelegate = self + return view + }() override func viewDidLoad() { super.viewDidLoad() + + navigationController?.navigationBar.titleTextAttributes = [ + NSAttributedStringKey.foregroundColor: UIColor.white, + NSAttributedStringKey.font: UIFont.systemFont(ofSize: 18.0, weight: .heavy) + ] + navigationController?.navigationBar.barTintColor = .red + navigationController?.navigationBar.tintColor = .white + navigationController?.navigationBar.isTranslucent = false + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Today", style: .done, target: self, action: #selector(didTapToday)) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + view.addSubview(monthlyView) + + NSLayoutConstraint.activate([ + monthlyView.topAnchor.constraint(equalTo: view.topAnchor), + monthlyView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + monthlyView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + monthlyView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + @objc func didTapToday() { + monthlyView.scrolltoCurrentMonth() + } + + // MARK: - PaiCalendarDelegate + + func calendarDateDidSelect(in calendar: MonthCollectionView, at index: Int, date: PaiDate) { + print("Selected date: \(date.date)") + } + + func calendarMonthViewDidScroll(in calendar: MonthCollectionView, at index: Int, month: String, year: String) { + title = month + " " + year + } + + // MARK: - PaiCalendarDataSourc + + func calendarDateEvents(in calendar: MonthCollectionView) -> [PaiDateEvent] { + let events = PaiDateEvent.generateRandom(numberOfEvents: 6) + return events } } diff --git a/Pai-Example/Pai-Example/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json b/Pai-Example/Pai-Example/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json index 1d060ed..d8db8d6 100644 --- a/Pai-Example/Pai-Example/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Pai-Example/Pai-Example/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -84,6 +84,11 @@ "idiom" : "ipad", "size" : "83.5x83.5", "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" } ], "info" : { diff --git a/Pai-Example/Pai-Example/Supporting Files/Info.plist b/Pai-Example/Pai-Example/Supporting Files/Info.plist index 16be3b6..668b2eb 100644 --- a/Pai-Example/Pai-Example/Supporting Files/Info.plist +++ b/Pai-Example/Pai-Example/Supporting Files/Info.plist @@ -28,6 +28,10 @@ armv7 + UIStatusBarStyle + UIStatusBarStyleLightContent + UIViewControllerBasedStatusBarAppearance + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/Pai.podspec b/Pai.podspec index 4a380bc..08505ad 100644 --- a/Pai.podspec +++ b/Pai.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Pai' - s.version = '0.0.1' + s.version = '0.1.0' s.license = { :type => "MIT", :file => "LICENSE.md" } s.summary = 'Calendar view library for iOS.' diff --git a/Pai.xcodeproj/project.pbxproj b/Pai.xcodeproj/project.pbxproj index dfcf600..726eeb4 100644 --- a/Pai.xcodeproj/project.pbxproj +++ b/Pai.xcodeproj/project.pbxproj @@ -7,9 +7,23 @@ objects = { /* Begin PBXBuildFile section */ + D54E06972005D0B3008E0DDD /* PaiStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54E06962005D0B3008E0DDD /* PaiStyle.swift */; }; + D54FC8EF1FECA8FD00EC148D /* MonthCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54FC8EE1FECA8FD00EC148D /* MonthCollectionView.swift */; }; + D54FC8F61FECB04E00EC148D /* DayViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54FC8F51FECB04E00EC148D /* DayViewCell.swift */; }; D588F2691FEBF48C00AEE201 /* Pai.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D588F25F1FEBF48C00AEE201 /* Pai.framework */; }; D588F26E1FEBF48C00AEE201 /* PaiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D588F26D1FEBF48C00AEE201 /* PaiTests.swift */; }; D588F2701FEBF48C00AEE201 /* Pai.h in Headers */ = {isa = PBXBuildFile; fileRef = D588F2621FEBF48C00AEE201 /* Pai.h */; settings = {ATTRIBUTES = (Public, ); }; }; + D5A0EB682007DA9E00CC4A2E /* PaiDateEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A0EB672007DA9E00CC4A2E /* PaiDateEvent.swift */; }; + D5D3AD8B2006F113002C74E4 /* UICollectionView+Custom.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D3AD8A2006F113002C74E4 /* UICollectionView+Custom.swift */; }; + D5D3AD9220071787002C74E4 /* PaiCalendarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D3AD9120071787002C74E4 /* PaiCalendarDelegate.swift */; }; + D5D3AD94200756B1002C74E4 /* PaiMonth.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D3AD93200756B1002C74E4 /* PaiMonth.swift */; }; + D5D3AD96200756F8002C74E4 /* PaiDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D3AD95200756F8002C74E4 /* PaiDate.swift */; }; + D5E3FCE51FF28CF90059433E /* MonthHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3FCE41FF28CF90059433E /* MonthHeaderView.swift */; }; + D5E3FCE71FF28D360059433E /* MonthVerticalFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3FCE61FF28D360059433E /* MonthVerticalFlowLayout.swift */; }; + D5E3FCE91FF2A2540059433E /* MonthViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3FCE81FF2A2540059433E /* MonthViewCell.swift */; }; + D5E3FCEB1FF2A78A0059433E /* PaiCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3FCEA1FF2A78A0059433E /* PaiCalendar.swift */; }; + D5E3FCF11FF2BBFA0059433E /* DayCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3FCF01FF2BBFA0059433E /* DayCollectionView.swift */; }; + D5E3FCF31FF2BC7C0059433E /* DayFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3FCF21FF2BC7C0059433E /* DayFlowLayout.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -23,12 +37,26 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + D54E06962005D0B3008E0DDD /* PaiStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PaiStyle.swift; path = Configs/PaiStyle.swift; sourceTree = ""; }; + D54FC8EE1FECA8FD00EC148D /* MonthCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthCollectionView.swift; sourceTree = ""; }; + D54FC8F51FECB04E00EC148D /* DayViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayViewCell.swift; sourceTree = ""; }; D588F25F1FEBF48C00AEE201 /* Pai.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pai.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D588F2621FEBF48C00AEE201 /* Pai.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Pai.h; sourceTree = ""; }; D588F2631FEBF48C00AEE201 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D588F2681FEBF48C00AEE201 /* PaiTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PaiTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D588F26D1FEBF48C00AEE201 /* PaiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaiTests.swift; sourceTree = ""; }; D588F26F1FEBF48C00AEE201 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D5A0EB672007DA9E00CC4A2E /* PaiDateEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaiDateEvent.swift; sourceTree = ""; }; + D5D3AD8A2006F113002C74E4 /* UICollectionView+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+Custom.swift"; sourceTree = ""; }; + D5D3AD9120071787002C74E4 /* PaiCalendarDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaiCalendarDelegate.swift; sourceTree = ""; }; + D5D3AD93200756B1002C74E4 /* PaiMonth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaiMonth.swift; sourceTree = ""; }; + D5D3AD95200756F8002C74E4 /* PaiDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaiDate.swift; sourceTree = ""; }; + D5E3FCE41FF28CF90059433E /* MonthHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthHeaderView.swift; sourceTree = ""; }; + D5E3FCE61FF28D360059433E /* MonthVerticalFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthVerticalFlowLayout.swift; sourceTree = ""; }; + D5E3FCE81FF2A2540059433E /* MonthViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthViewCell.swift; sourceTree = ""; }; + D5E3FCEA1FF2A78A0059433E /* PaiCalendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaiCalendar.swift; sourceTree = ""; }; + D5E3FCF01FF2BBFA0059433E /* DayCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayCollectionView.swift; sourceTree = ""; }; + D5E3FCF21FF2BC7C0059433E /* DayFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayFlowLayout.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,6 +78,39 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + D54E06952005D071008E0DDD /* Configs */ = { + isa = PBXGroup; + children = ( + D54E06962005D0B3008E0DDD /* PaiStyle.swift */, + ); + name = Configs; + sourceTree = ""; + }; + D54FC8EB1FECA85500EC148D /* Views */ = { + isa = PBXGroup; + children = ( + D54FC8EE1FECA8FD00EC148D /* MonthCollectionView.swift */, + D5E3FCE61FF28D360059433E /* MonthVerticalFlowLayout.swift */, + D5E3FCE41FF28CF90059433E /* MonthHeaderView.swift */, + D5E3FCE81FF2A2540059433E /* MonthViewCell.swift */, + D5E3FCF01FF2BBFA0059433E /* DayCollectionView.swift */, + D5E3FCF21FF2BC7C0059433E /* DayFlowLayout.swift */, + D54FC8F51FECB04E00EC148D /* DayViewCell.swift */, + ); + path = Views; + sourceTree = ""; + }; + D54FC8ED1FECA86200EC148D /* Models */ = { + isa = PBXGroup; + children = ( + D5E3FCEA1FF2A78A0059433E /* PaiCalendar.swift */, + D5D3AD93200756B1002C74E4 /* PaiMonth.swift */, + D5D3AD95200756F8002C74E4 /* PaiDate.swift */, + D5A0EB672007DA9E00CC4A2E /* PaiDateEvent.swift */, + ); + path = Models; + sourceTree = ""; + }; D588F2551FEBF48B00AEE201 = { isa = PBXGroup; children = ( @@ -98,10 +159,31 @@ D588F27A1FEBF4F700AEE201 /* Sources */ = { isa = PBXGroup; children = ( + D5D3AD8C20070E46002C74E4 /* Protocols */, + D5D3AD892006F0EC002C74E4 /* Extensions */, + D54FC8ED1FECA86200EC148D /* Models */, + D54FC8EB1FECA85500EC148D /* Views */, + D54E06952005D071008E0DDD /* Configs */, ); path = Sources; sourceTree = ""; }; + D5D3AD892006F0EC002C74E4 /* Extensions */ = { + isa = PBXGroup; + children = ( + D5D3AD8A2006F113002C74E4 /* UICollectionView+Custom.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + D5D3AD8C20070E46002C74E4 /* Protocols */ = { + isa = PBXGroup; + children = ( + D5D3AD9120071787002C74E4 /* PaiCalendarDelegate.swift */, + ); + path = Protocols; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -165,6 +247,7 @@ TargetAttributes = { D588F25E1FEBF48C00AEE201 = { CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 0920; ProvisioningStyle = Automatic; }; D588F2671FEBF48C00AEE201 = { @@ -229,6 +312,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D5E3FCEB1FF2A78A0059433E /* PaiCalendar.swift in Sources */, + D5A0EB682007DA9E00CC4A2E /* PaiDateEvent.swift in Sources */, + D54E06972005D0B3008E0DDD /* PaiStyle.swift in Sources */, + D5E3FCE91FF2A2540059433E /* MonthViewCell.swift in Sources */, + D5E3FCE71FF28D360059433E /* MonthVerticalFlowLayout.swift in Sources */, + D5E3FCF31FF2BC7C0059433E /* DayFlowLayout.swift in Sources */, + D5D3AD96200756F8002C74E4 /* PaiDate.swift in Sources */, + D5D3AD94200756B1002C74E4 /* PaiMonth.swift in Sources */, + D5E3FCE51FF28CF90059433E /* MonthHeaderView.swift in Sources */, + D54FC8EF1FECA8FD00EC148D /* MonthCollectionView.swift in Sources */, + D5D3AD9220071787002C74E4 /* PaiCalendarDelegate.swift in Sources */, + D5D3AD8B2006F113002C74E4 /* UICollectionView+Custom.swift in Sources */, + D5E3FCF11FF2BBFA0059433E /* DayCollectionView.swift in Sources */, + D54FC8F61FECB04E00EC148D /* DayViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -367,6 +464,7 @@ D588F2741FEBF48C00AEE201 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -380,6 +478,7 @@ PRODUCT_BUNDLE_IDENTIFIER = me.luqmanfauzi.Pai; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 4.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -388,6 +487,7 @@ D588F2751FEBF48C00AEE201 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -409,6 +509,7 @@ D588F2771FEBF48C00AEE201 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = R2XF3NYPAG; INFOPLIST_FILE = PaiTests/Info.plist; @@ -423,6 +524,7 @@ D588F2781FEBF48C00AEE201 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = R2XF3NYPAG; INFOPLIST_FILE = PaiTests/Info.plist; diff --git a/Pai/Sources/Configs/PaiStyle.swift b/Pai/Sources/Configs/PaiStyle.swift new file mode 100644 index 0000000..cc8c0cd --- /dev/null +++ b/Pai/Sources/Configs/PaiStyle.swift @@ -0,0 +1,44 @@ +// +// PaiStyle.swift +// Pai +// +// Created by Luqman Fauzi on 10/01/2018. +// Copyright © 2018 Luqman Fauzi. All rights reserved. +// + +import Foundation + +public enum DateItemStyle { + case today, insetDate, offsetDate, pastDate +} + +public class PaiStyle { + + private init() { } + public static let shared = PaiStyle() + + public var dateItemFont: UIFont = UIFont.systemFont(ofSize: 17.0, weight: .medium) + public var dateItemNormalTextColor: UIColor = .darkGray + public var dateItemExcludedTextColor: UIColor = .lightGray + public var dateItemBackgroundColor: UIColor = .white + public var dateItemTodayIndicatorColor: UIColor = .red + public var dateItemTodayIndicatorTextColor: UIColor = .white + public var dateItemShouldHideOffsetDates: Bool = false + public var dateItemShouldGreyOutPastDates: Bool = false + public var dateItemShouldDisplayLine: Bool = false + + public var dateItemSymbolFont: UIFont = UIFont.systemFont(ofSize: 17.0, weight: .bold) + public var dateItemSymbolTextColor: UIColor = .black + public var dateItemSymbolBackgroundColor: UIColor = .white + public var dateItemDayLabelInset: UIEdgeInsets = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0) + + public var monthItemHeaderFont: UIFont = UIFont.systemFont(ofSize: 20.0, weight: .heavy) + public var monthItemHeaderTextAlignment: NSTextAlignment = .left + public var monthItemHeaderBackgroundColor: UIColor = .white + public var monthItemHeaderTextColor: UIColor = .red + public var monthItemHeaderShouldPin: Bool = false + public var monthItemHeaderHeight: CGFloat = 50.0 + public var monthItemPadding: CGFloat = 0 + + public var dateItemDisplayEventsIfAny: Bool = false +} diff --git a/Pai/Sources/Extensions/UICollectionView+Custom.swift b/Pai/Sources/Extensions/UICollectionView+Custom.swift new file mode 100644 index 0000000..32fef1f --- /dev/null +++ b/Pai/Sources/Extensions/UICollectionView+Custom.swift @@ -0,0 +1,68 @@ +// +// UICollectionView+Custom.swift +// Pai +// +// Created by Luqman Fauzi on 22/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// Credits: https://github.com/SwifterSwift/SwifterSwift/blob/master/Sources/Extensions/UIKit/UICollectionViewExtensions.swift + +import UIKit + +internal extension UICollectionView { + + /// SwifterSwift: Dequeue reusable UICollectionViewCell using class name. + /// + /// - Parameters: + /// - name: UICollectionViewCell type. + /// - indexPath: location of cell in collectionView. + /// - Returns: UICollectionViewCell object with associated class name. + internal func dequeueReusableCell(withClass name: T.Type, for indexPath: IndexPath) -> T? { + return dequeueReusableCell(withReuseIdentifier: String(describing: name), for: indexPath) as? T + } + + /// SwifterSwift: Dequeue reusable UICollectionReusableView using class name. + /// + /// - Parameters: + /// - kind: the kind of supplementary view to retrieve. This value is defined by the layout object. + /// - name: UICollectionReusableView type. + /// - indexPath: location of cell in collectionView. + /// - Returns: UICollectionReusableView object with associated class name. + internal func dequeueReusableSupplementaryView(ofKind kind: String, withClass name: T.Type, for indexPath: IndexPath) -> T? { + return dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: name), for: indexPath) as? T + } + + /// SwifterSwift: Register UICollectionReusableView using class name. + /// + /// - Parameters: + /// - kind: the kind of supplementary view to retrieve. This value is defined by the layout object. + /// - name: UICollectionReusableView type. + internal func register(supplementaryViewOfKind kind: String, withClass name: T.Type) { + register(T.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: String(describing: name)) + } + + /// SwifterSwift: Register UICollectionViewCell using class name. + /// + /// - Parameters: + /// - nib: Nib file used to create the collectionView cell. + /// - name: UICollectionViewCell type. + internal func register(nib: UINib?, forCellWithClass name: T.Type) { + register(nib, forCellWithReuseIdentifier: String(describing: name)) + } + + /// SwifterSwift: Register UICollectionViewCell using class name. + /// + /// - Parameter name: UICollectionViewCell type. + internal func register(cellWithClass name: T.Type) { + register(T.self, forCellWithReuseIdentifier: String(describing: name)) + } + + /// SwifterSwift: Register UICollectionReusableView using class name. + /// + /// - Parameters: + /// - nib: Nib file used to create the reusable view. + /// - kind: the kind of supplementary view to retrieve. This value is defined by the layout object. + /// - name: UICollectionReusableView type. + internal func register(nib: UINib?, forSupplementaryViewOfKind kind: String, withClass name: T.Type) { + register(nib, forSupplementaryViewOfKind: kind, withReuseIdentifier: String(describing: name)) + } +} diff --git a/Pai/Sources/Models/PaiCalendar.swift b/Pai/Sources/Models/PaiCalendar.swift new file mode 100644 index 0000000..fb9e24d --- /dev/null +++ b/Pai/Sources/Models/PaiCalendar.swift @@ -0,0 +1,103 @@ +// +// PaiCalendar.swift +// Pai +// +// Created by Luqman Fauzi on 26/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import Foundation + +public class PaiCalendar { + + public static let current = PaiCalendar() + private init() { } + + private let calendar = Calendar.autoupdatingCurrent + private let today = Date() + + public var shortMonthSymbols: [String] { + return calendar.shortMonthSymbols + } + + public var veryShortWeekdaySymbols: [String] { + return calendar.veryShortWeekdaySymbols + } +} + +public extension PaiCalendar { + + // MARK: - Days in Month, UI related + + /// Construct the `[PaiDate]` list of a particular month & year according to `[DayViewCell]` scope. + /// + /// - Parameter month: `PaiMonth` which contains month & year values. + /// - Returns: list of `[PaiDate]` of given month & year. + public func datesCountInMonth(inMonth month: PaiMonth) -> [PaiDate] { + guard let startIndex = indexStartDate(inMonth: month) else { + fatalError("Index not found") + } + let date = startDate(inMonth: month) + let count = numberOfItemMonthCells(inMonth: month) + let dates: [PaiDate] = (0.. Int { + if let weeksRange = calendar.range(of: .weekOfMonth, in: .month, for: startDate(inMonth: month)) { + let count = weeksRange.upperBound - weeksRange.lowerBound + return count * 7 + } + return 0 + } + + // MARK: - Days in Month, data related + + /// Get the starting date of particular month & year + /// + /// - Parameter month: `PaiMonth` which contains month & year values. + /// - Returns: the first `Date` of particular month & year. + private func startDate(inMonth month: PaiMonth) -> Date { + var components = calendar.dateComponents([.year, .month, .day], from: today) + components.day = 1 + components.month = month.month.rawValue + 1 + components.year = month.year + return calendar.date(from: components) ?? Date() + } + + /// Get the index value of first `Date` of particular month & year in the `MonthViewCell` content / `[DayViewCell]` list. + /// In order to set the date month opening 'edge' date of the month. + /// - Parameter month: `PaiMonth` which contains month & year values. + /// - Returns: `Int` index value of the first date of particular onth & year values. + public func indexStartDate(inMonth month: PaiMonth) -> Int? { + let date = startDate(inMonth: month) + if let index = calendar.ordinality(of: .day, in: .weekOfMonth, for: date) { + return index - 1 + } + return nil + } + + /// Get the index value of last `Date` of particular month & year in the `MonthViewCell` content / `[DayViewCell]`. + /// In order to set the date month closure 'edge' date of the month. + /// - Parameter month: `PaiMonth` which contains month & year values. + /// - Returns: `Int` index value of the first date of particular onth & year values. + public func indexEndDate(inMonth month: PaiMonth) -> Int? { + let date = startDate(inMonth: month) + let startIndex = indexStartDate(inMonth: month) + if let rangeDays = calendar.range(of: .day, in: .month, for: date), let beginning = startIndex { + let count = rangeDays.upperBound - rangeDays.lowerBound + return count + beginning - 1 + } + return nil + } +} diff --git a/Pai/Sources/Models/PaiDate.swift b/Pai/Sources/Models/PaiDate.swift new file mode 100644 index 0000000..ae04c3f --- /dev/null +++ b/Pai/Sources/Models/PaiDate.swift @@ -0,0 +1,22 @@ +// +// PaiDate.swift +// Pai +// +// Created by Luqman Fauzi on 11/01/2018. +// Copyright © 2018 Luqman Fauzi. All rights reserved. +// + +import Foundation + +public struct PaiDate { + + public let date: Date + + public var isPastDate: Bool { + return Calendar.autoupdatingCurrent.compare(date, to: Date(), toGranularity: .day) == .orderedAscending + } + + public var isToday: Bool { + return Calendar.autoupdatingCurrent.compare(date, to: Date(), toGranularity: .day) == .orderedSame + } +} diff --git a/Pai/Sources/Models/PaiDateEvent.swift b/Pai/Sources/Models/PaiDateEvent.swift new file mode 100644 index 0000000..4a57bab --- /dev/null +++ b/Pai/Sources/Models/PaiDateEvent.swift @@ -0,0 +1,40 @@ +// +// PaiDateEvent.swift +// Pai +// +// Created by Luqman Fauzi on 12/01/2018. +// Copyright © 2018 Luqman Fauzi. All rights reserved. +// + +import Foundation + +public struct PaiDateEvent { + + public let date: Date + + public let name: String? + + public let tagColor: UIColor + + /// Initlizer of the struct + /// + /// - Parameters: + /// - date: `Date` of event + /// - name: optional `String` of event name + /// - tagColor: `UIColor` tag color of event + public static func initObject(date: Date, name: String?, tagColor: UIColor) -> PaiDateEvent { + return PaiDateEvent(date: date, name: name, tagColor: tagColor) + } +} + +public extension PaiDateEvent { + public static func generateRandom(numberOfEvents: Int) -> [PaiDateEvent] { + var events: [PaiDateEvent] = [] + for i in 1...numberOfEvents { + let color: UIColor = (i % 2 == 0) ? .red : .blue + let event = PaiDateEvent(date: Date(), name: nil, tagColor: color) + events.append(event) + } + return events + } +} diff --git a/Pai/Sources/Models/PaiMonth.swift b/Pai/Sources/Models/PaiMonth.swift new file mode 100644 index 0000000..d6d93f7 --- /dev/null +++ b/Pai/Sources/Models/PaiMonth.swift @@ -0,0 +1,108 @@ +// +// PaiMonth.swift +// Pai +// +// Created by Luqman Fauzi on 11/01/2018. +// Copyright © 2018 Luqman Fauzi. All rights reserved. +// + +import Foundation + +public enum Month: Int { + case jan, feb, mar, apr, may, jun, jul, aug, sept, oct, nov, dec +} + +public struct PaiMonth { + let year: Int + let symbol: String + let month: Month +} + +public extension PaiMonth { + + /// Generates all the months within particular range of years. + /// + /// - Parameters: + /// - startYear: starting year + /// - endYear: end year + /// - Returns: list of `[PaiMonth]` + static func generatesInYears(from startYear: Int, to endYear: Int) -> [PaiMonth] { + guard endYear >= startYear else { + fatalError("The end year must be greater than or equal to start year.") + } + var months: [PaiMonth] = [] + for year in startYear...endYear { + for (index, symbol) in Calendar.autoupdatingCurrent.shortMonthSymbols.enumerated() { + let month = Month(rawValue: index)! + let paiMonth = PaiMonth(year: year, symbol: symbol, month: month) + months.append(paiMonth) + } + } + return months + } + + /// Generates all the months within backward & forward range of months. + /// + /// - Parameters: + /// - backwardsCount: the number of months backward from now. + /// - forwardsCount: the number of months forward from now. + /// - Returns: list of `[PaiMonth]` + static func generatesInMonts(backwardsCount: Int, forwardsCount: Int) -> [PaiMonth] { + let calendar = Calendar.autoupdatingCurrent + let setComponents: Set = [.year, .month, .day] + let monthSymbols: [String] = calendar.monthSymbols + let today = Date() + let yearFormatter = DateFormatter() + yearFormatter.dateFormat = "yyyy" + + var backwardsDateComponent = calendar.dateComponents(setComponents, from: today) + var forwardsDateComponent = calendar.dateComponents(setComponents, from: today) + let currentDateComponent = calendar.dateComponents(setComponents, from: today) + let currentMonth = currentDateComponent.month! + + backwardsDateComponent.month = currentMonth - backwardsCount + forwardsDateComponent.month = currentMonth + forwardsCount + + let firstDate = calendar.date(from: backwardsDateComponent)! + let firstMonthOrdinaly = calendar.ordinality(of: .month, in: .year, for: firstDate)! + let firstYear = Int(yearFormatter.string(from: firstDate))! + + let lastDate = calendar.date(from: forwardsDateComponent)! + let lastMonthOrdinaly = calendar.ordinality(of: .month, in: .year, for: lastDate)! + let lastYear = Int(yearFormatter.string(from: lastDate))! + + var monthsInYears: [PaiMonth] = [] + + for year in firstYear...lastYear { + if year == firstYear { + /// Left edge year + let months: [PaiMonth] = Array(firstMonthOrdinaly...monthSymbols.count).enumerated().flatMap({ + let index = $0.element - 1 + let month = Month(rawValue: index)! + let symbol = monthSymbols[index] + let paiMonth = PaiMonth(year: year, symbol: symbol, month: month) + return paiMonth + }) + monthsInYears.append(contentsOf: months) + } else if year == lastYear { + /// Right edge year + let months: [PaiMonth] = Array(1...lastMonthOrdinaly).enumerated().flatMap({ + let index = $0.element - 1 + let month = Month(rawValue: index)! + let symbol = monthSymbols[index] + let paiMonth = PaiMonth(year: year, symbol: symbol, month: month) + return paiMonth + }) + monthsInYears.append(contentsOf: months) + } else { + let months: [PaiMonth] = monthSymbols.enumerated().flatMap({ + let month = Month(rawValue: $0.offset)! + let paiMonth = PaiMonth(year: year, symbol: $0.element, month: month) + return paiMonth + }) + monthsInYears.append(contentsOf: months) + } + } + return monthsInYears + } +} diff --git a/Pai/Sources/Protocols/PaiCalendarDelegate.swift b/Pai/Sources/Protocols/PaiCalendarDelegate.swift new file mode 100644 index 0000000..196f770 --- /dev/null +++ b/Pai/Sources/Protocols/PaiCalendarDelegate.swift @@ -0,0 +1,49 @@ +// +// PaiCalendarDelegate.swift +// Pai +// +// Created by Luqman Fauzi on 11/01/2018. +// Copyright © 2018 Luqman Fauzi. All rights reserved. +// + +import Foundation + +public protocol PaiCalendarDataSource: class { + + /// List of `[PaiDateEvent]` events to be displayed in calendar + /// + /// - Parameter calendar: `MonthCollectionView` + /// - Returns: list of all events to be displayed in the calendar view + func calendarDateEvents(in calendar: MonthCollectionView) -> [PaiDateEvent] +} + +public extension PaiCalendarDataSource { + func calendarDateEvents(in calendar: MonthCollectionView) -> [PaiDateEvent] { + return [] + } +} + +public protocol PaiCalendarDelegate: class { + + /// Send event on tapping specific date in month + /// + /// - Parameters: + /// - calendar: `MonthCollectionView` + /// - index: index of selected date in particular month + /// - date: selected `PaiDate` in particular month + func calendarDateDidSelect(in calendar: MonthCollectionView, at index: Int, date: PaiDate) + + /// Send month string when month cell is currently at top of screen + /// + /// - Parameters: + /// - calendar: `MonthCollectionView` + /// - index: index of selected month + /// - month: string of selected month + /// - year: string of selected year + func calendarMonthViewDidScroll(in calendar: MonthCollectionView, at index: Int, month: String, year: String) +} + +public extension PaiCalendarDelegate { + func calendarDateDidSelect(in calendar: MonthCollectionView, at index: Int, date: PaiDate) { } + func calendarMonthViewDidScroll(in calendar: MonthCollectionView, at index: Int, month: String, year: String) { } +} diff --git a/Pai/Sources/Views/DayCollectionView.swift b/Pai/Sources/Views/DayCollectionView.swift new file mode 100644 index 0000000..10ac780 --- /dev/null +++ b/Pai/Sources/Views/DayCollectionView.swift @@ -0,0 +1,129 @@ +// +// DayCollectionView.swift +// Pai +// +// Created by Luqman Fauzi on 27/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import Foundation + +internal typealias DailyEventsItem = (date: Date, events: [PaiDateEvent]) + +public class DayCollectionView: UICollectionView { + + // MARK: - Public Properties + + private var month: PaiMonth? { + didSet { + guard let month = month else { return } + dates = PaiCalendar.current.datesCountInMonth(inMonth: month) + } + } + + // MARK: - Private Properties + + private var dailyEventsItems: [DailyEventsItem] = [] { + didSet { + reloadData() + } + } + + private var dates: [PaiDate] = [] { + didSet { + reloadData() + } + } + + public init() { + let layout = DayFlowLayout() + super.init(frame: .zero, collectionViewLayout: layout) + register(cellWithClass: DayViewCell.self) + backgroundColor = UIColor.white + translatesAutoresizingMaskIntoConstraints = false + delegate = self + dataSource = self + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + /// Configure `PaiMonth` & its `[PaiDateEvent]` events for particular month + /// + /// - Parameters: + /// - month: `PaiMonth` + /// - events: events in particular `PaiMonth` + public func configure(_ month: PaiMonth, _ events: [PaiDateEvent]) { + self.month = month + /// Map events of the particular date, according to collectionView item index. + var items: [DailyEventsItem] = [] + dates.map({ $0.date }).forEach { (date) in + let dailyEvents = events.filter({ + Calendar.autoupdatingCurrent.compare($0.date, to: date, toGranularity: .day) == .orderedSame + }) + let item: DailyEventsItem = (date, dailyEvents) + items.append(item) + } + dailyEventsItems = items + } +} + +extension DayCollectionView: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + + // MARK: - UICollectionViewDataSource + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return dates.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard + let cell = collectionView.dequeueReusableCell(withClass: DayViewCell.self, for: indexPath), + let monthItem = month + else { + fatalError("DayViewCell not found.") + } + + let dateItem = dates[indexPath.item] + cell.configure(date: dateItem.date) + + /// Configure date item cell by the 3 defined `DateItemStyle` values + if let beginning = PaiCalendar.current.indexStartDate(inMonth: monthItem), indexPath.item < beginning { + cell.configure(style: .offsetDate) + } else if let end = PaiCalendar.current.indexEndDate(inMonth: monthItem), indexPath.item > end { + cell.configure(style: .offsetDate) + } else { + if dateItem.isPastDate { + cell.configure(style: .pastDate) + } else if dateItem.isToday { + cell.configure(style: .today) + } else { + cell.configure(style: .insetDate) + } + } + + /// Configure events for particular date + let eventItem = dailyEventsItems[indexPath.item] + cell.configureEvent(item: eventItem) + + return cell + } + + // MARK: - UICollectionViewDelegate + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let item = dates[indexPath.item] + let index = indexPath.item + let object: (PaiDate, Int) = (item, index) + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "me.luqmanfauzi.Pai"), object: object) + } + + // MARK: - UICollectionViewDelegateFlowLayout + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let itemWidth: CGFloat = collectionView.bounds.width / CGFloat(7) + let itemHeight: CGFloat = itemWidth + return CGSize(width: itemWidth, height: itemHeight) + } +} diff --git a/Pai/Sources/Views/DayFlowLayout.swift b/Pai/Sources/Views/DayFlowLayout.swift new file mode 100644 index 0000000..db09949 --- /dev/null +++ b/Pai/Sources/Views/DayFlowLayout.swift @@ -0,0 +1,22 @@ +// +// DayFlowLayout.swift +// Pai +// +// Created by Luqman Fauzi on 27/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import Foundation + +final class DayFlowLayout: UICollectionViewFlowLayout { + + override init() { + super.init() + minimumInteritemSpacing = 0 + minimumLineSpacing = 0 + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } +} diff --git a/Pai/Sources/Views/DayViewCell.swift b/Pai/Sources/Views/DayViewCell.swift new file mode 100644 index 0000000..d695cf4 --- /dev/null +++ b/Pai/Sources/Views/DayViewCell.swift @@ -0,0 +1,160 @@ +// +// DayViewCell.swift +// Pai +// +// Created by Luqman Fauzi on 22/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import UIKit + +final class DayViewCell: UICollectionViewCell { + + // MARK: - Private Properties + + private lazy var dateLabel: UILabel = { + let label = UILabel() + label.font = PaiStyle.shared.dateItemFont + label.textAlignment = .center + label.clipsToBounds = true + label.isUserInteractionEnabled = true + return label + }() + + private lazy var todayIndicator: CALayer = { + let layer = CALayer() + return layer + }() + + private lazy var topLine: CALayer = { + let layer = CALayer() + return layer + }() + + private lazy var dotsIndicator: UIView = { + let view = UIView() + view.frame = CGRect(x: 0, y: 0, width: self.bounds.width - 15.0, height: 20.0) + for i in 0...2 { + let accumulator = CGFloat(i) * 3.5 + let x = (view.bounds.width * 0.5) - 3.5 + accumulator + let frame = CGRect(x: x, y: 1.0, width: 1.5, height: 1.5) + let dotPath = UIBezierPath(ovalIn: frame) + let layer = CAShapeLayer() + layer.path = dotPath.cgPath + layer.strokeColor = UIColor.lightGray.cgColor + view.layer.addSublayer(layer) + } + return view + }() + + private lazy var eventViews: [UIView] = { + let maxStacks = 6 + var views: [UIView] = [] + for i in 1...maxStacks { + let view: UIView + if i == maxStacks { + view = dotsIndicator + } else { + view = UIView() + } + view.translatesAutoresizingMaskIntoConstraints = false + view.heightAnchor.constraint(equalToConstant: 3.0).isActive = true + views.append(view) + } + return views + }() + + private lazy var eventsStackView: UIStackView = { + let view = UIStackView(arrangedSubviews: self.eventViews) + view.translatesAutoresizingMaskIntoConstraints = false + view.axis = .vertical + view.isBaselineRelativeArrangement = true + view.distribution = .equalSpacing + view.alignment = .fill + view.spacing = 1.0 + return view + }() + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.backgroundColor = PaiStyle.shared.dateItemBackgroundColor + + topLine.frame = CGRect(x: 0, y: 0.7, width: bounds.width, height: 0.7) + topLine.backgroundColor = UIColor.clear.cgColor + layer.addSublayer(topLine) + + let labelFrame = UIEdgeInsetsInsetRect(bounds, PaiStyle.shared.dateItemDayLabelInset) + todayIndicator.frame = labelFrame + todayIndicator.cornerRadius = todayIndicator.frame.height * 0.5 + layer.addSublayer(todayIndicator) + + dateLabel.frame = labelFrame + addSubview(dateLabel) + + if PaiStyle.shared.dateItemDisplayEventsIfAny { + addSubview(eventsStackView) + NSLayoutConstraint.activate([ + eventsStackView.topAnchor.constraint(equalTo: topAnchor, constant: labelFrame.height + 5.0), + eventsStackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + eventsStackView.centerXAnchor.constraint(equalTo: centerXAnchor), + eventsStackView.widthAnchor.constraint(equalToConstant: bounds.width - 15.0) + ]) + } + } + + public func configure(date: Date) { + let formatter = DateFormatter() + formatter.dateFormat = "d" + dateLabel.text = formatter.string(from: date) + } + + public func configureEvent(item: DailyEventsItem) { + guard PaiStyle.shared.dateItemDisplayEventsIfAny, !(item.events.isEmpty) else { + eventsStackView.isHidden = true + return + } + eventsStackView.isHidden = false + if item.events.count <= 5 { + /// Remove last subview in stackview. + eventsStackView.removeArrangedSubview(eventsStackView.arrangedSubviews.last!) + eventViews.last?.removeFromSuperview() + } + for (index, event) in item.events.enumerated() { + if 0...4 ~= index { + /// Within 5 events range + eventsStackView.arrangedSubviews[index].backgroundColor = event.tagColor + } else { + break + } + } + } + + public func configure(style: DateItemStyle) { + let topLineColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor + let isDisplayDateTopLine = PaiStyle.shared.dateItemShouldDisplayLine + let isPastDateGrayOut = PaiStyle.shared.dateItemShouldGreyOutPastDates + let isOffsetDateHidden = PaiStyle.shared.dateItemShouldHideOffsetDates + let insetDateColor = PaiStyle.shared.dateItemNormalTextColor + let offsetDateColor = PaiStyle.shared.dateItemExcludedTextColor + + switch style { + case .pastDate: + topLine.backgroundColor = isDisplayDateTopLine ? topLineColor : UIColor.clear.cgColor + todayIndicator.backgroundColor = UIColor.clear.cgColor + dateLabel.textColor = isPastDateGrayOut ? .lightGray : insetDateColor + case .today: + topLine.backgroundColor = isDisplayDateTopLine ? topLineColor : UIColor.clear.cgColor + todayIndicator.backgroundColor = PaiStyle.shared.dateItemTodayIndicatorColor.cgColor + dateLabel.textColor = PaiStyle.shared.dateItemTodayIndicatorTextColor + case .insetDate: + topLine.backgroundColor = isDisplayDateTopLine ? topLineColor : UIColor.clear.cgColor + todayIndicator.backgroundColor = UIColor.clear.cgColor + dateLabel.textColor = insetDateColor + case .offsetDate: + topLine.backgroundColor = UIColor.clear.cgColor + todayIndicator.backgroundColor = UIColor.clear.cgColor + dateLabel.textColor = isOffsetDateHidden ? .clear : offsetDateColor + } + } +} diff --git a/Pai/Sources/Views/MonthCollectionView.swift b/Pai/Sources/Views/MonthCollectionView.swift new file mode 100644 index 0000000..4a33435 --- /dev/null +++ b/Pai/Sources/Views/MonthCollectionView.swift @@ -0,0 +1,223 @@ +// +// MonthCollectionView.swift +// Pai +// +// Created by Luqman Fauzi on 22/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import UIKit + +internal typealias MonthlyEventsItem = (month: PaiMonth, events: [PaiDateEvent]) + +public class MonthCollectionView: UICollectionView { + + // MARK: - Public Properties + + public var sharedStyle: PaiStyle + public weak var calendarDelegate: PaiCalendarDelegate? + + // MARK: - Private Properties + + private var months: [PaiMonth]! + private var montlyEventsItems: [MonthlyEventsItem] = [] { + didSet { + reloadData() + } + } + private var mostTopMonth: PaiMonth? + private var currentlyScrollToCurrentMonth = false + private var currentMonthIndex: IndexPath!{ + didSet{ + currentlyScrollToCurrentMonth = true + } + } + + public init(style: PaiStyle, startYear: Int, endYear: Int, calendarDataSource: PaiCalendarDataSource? = nil) { + sharedStyle = style + months = PaiMonth.generatesInYears(from: startYear, to: endYear) + super.init(frame: .zero, collectionViewLayout: MonthVerticalFlowLayout()) + sharedInit(calendarDataSource: calendarDataSource) + } + + public init(style: PaiStyle, backwardsMonths: Int, forwardsMonths: Int, calendarDataSource: PaiCalendarDataSource? = nil) { + sharedStyle = style + months = PaiMonth.generatesInMonts(backwardsCount: backwardsMonths, forwardsCount: backwardsMonths) + super.init(frame: .zero, collectionViewLayout: MonthVerticalFlowLayout()) + sharedInit(calendarDataSource: calendarDataSource) + } + + private func sharedInit(calendarDataSource: PaiCalendarDataSource? = nil) { + /// Setup UI + register(cellWithClass: MonthViewCell.self) + register(supplementaryViewOfKind: UICollectionElementKindSectionHeader, withClass: MonthHeaderView.self) + backgroundColor = UIColor.groupTableViewBackground + translatesAutoresizingMaskIntoConstraints = false + delegate = self + dataSource = self + showsVerticalScrollIndicator = false + NotificationCenter.default.addObserver(self, selector: #selector(dateDidSelect), name: NSNotification.Name(rawValue: "me.luqmanfauzi.Pai"), object: nil) + + /// Setup date events + if let events = calendarDataSource?.calendarDateEvents(in: self) { + mapEventsForParticularMonths(events: events) + } + + /// Scroll to current date if needed + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.scrolltoCurrentMonth() + } + } + + required public init?(coder aDecoder: NSCoder) { + sharedStyle = PaiStyle.shared + super.init(coder: aDecoder) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Private Methods + + /// Notification center event from tapping day item cell. + /// + /// - Parameter notification: `Notification` event from NotificationCenter + @objc private func dateDidSelect(_ notification: Notification) { + guard let object = notification.object as? (PaiDate, Int) else { + return + } + let date = object.0 + let index = object.1 + calendarDelegate?.calendarDateDidSelect(in: self, at: index, date: date) + } + + /// Map all events into particular months + /// + /// - Parameter events: All `[PaiDateEvent]` events from outside library. + private func mapEventsForParticularMonths(events: [PaiDateEvent]) { + var items: [MonthlyEventsItem] = [] + months.forEach { (monthItem) in + let currentMonthNumber: String = (monthItem.month.rawValue + 1).description + let currentYear: String = monthItem.year.description + + /// Get all events in this particular month & year. + let events = events.filter({ event in + /// Filter event of the year. + let formatter = DateFormatter() + formatter.dateFormat = "yyyy" + let yearString: String = formatter.string(from: event.date) + return (currentYear == yearString) + }).filter({ event in + /// Filter event of the month. + let formatter = DateFormatter() + formatter.dateFormat = "M" + let monthNumber: String = formatter.string(from: event.date) + return (currentMonthNumber == monthNumber) + }) + + let monthlyEvent: MonthlyEventsItem = (monthItem, events) + items.append(monthlyEvent) + } + montlyEventsItems = items + } + + /// Scroll to current month, which contains today. + /// + /// - Parameter animated: animation effect upon dragging. + public func scrolltoCurrentMonth() { + let components = Calendar.autoupdatingCurrent.dateComponents([.year, .month, .day], from: Date()) + let currentMonth = components.month + let currentYear = components.year + guard let indexTarget = months.enumerated() + .filter({ $0.element.year == currentYear }) + .filter({ $0.element.month.rawValue + 1 == currentMonth }) + .first?.offset + else { + return + } + let indexPath = IndexPath(item: 0, section: indexTarget) + scrollToItem(at: indexPath, at: .top, animated: true) + currentMonthIndex = indexPath + } +} + +extension MonthCollectionView: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + + // MARK: - UICollectionViewDataSource + + public func numberOfSections(in collectionView: UICollectionView) -> Int { + return months.count + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return 1 + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withClass: MonthViewCell.self, for: indexPath) else { + fatalError("DayViewCell not found.") + } + let item = montlyEventsItems[indexPath.section] + cell.configure(eventsItem: item) + return cell + } + + // MARK: - UICollectionViewDelegateFlowLayout + + public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + guard let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: + UICollectionElementKindSectionHeader, + withClass: MonthHeaderView.self, + for: indexPath) + else { + fatalError("MonthlyHeaderReusableView not found.") + } + let month = months[indexPath.section] + headerView.configure(monthSymbol: month.symbol, year: month.year) + return headerView + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let width = collectionView.bounds.width + let month = months[indexPath.section] + let itemsCountInSection = PaiCalendar.current.numberOfItemMonthCells(inMonth: month) + let itemsCountInRow = itemsCountInSection / 7 + let dateItemHeight: CGFloat = width / 7 + let symbolsHeight: CGFloat = 25.0 + let monthHeight: CGFloat = CGFloat(itemsCountInRow) * dateItemHeight + symbolsHeight + return CGSize(width: width, height: monthHeight) + } + + // MARK: - UIScrollView Delegate + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let pointY = scrollView.contentOffset.y + PaiStyle.shared.monthItemHeaderHeight + let point = CGPoint(x: 0, y: pointY) + if let indexPath = indexPathForItem(at: point) { + let selectedMonth = months[indexPath.section] + if mostTopMonth == nil { + mostTopMonth = selectedMonth + calendarDelegate?.calendarMonthViewDidScroll(in: self, at: indexPath.section, month: selectedMonth.symbol, year: "\(selectedMonth.year)") + } else { + let aldyDisplayMonth = mostTopMonth?.month == selectedMonth.month && mostTopMonth?.year == selectedMonth.year + if !aldyDisplayMonth { + mostTopMonth = selectedMonth + calendarDelegate?.calendarMonthViewDidScroll(in: self, at: indexPath.section, month: selectedMonth.symbol, year: "\(selectedMonth.year)") + } + } + } + } + + public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + if currentlyScrollToCurrentMonth { + currentlyScrollToCurrentMonth = false + let offsetY = (PaiStyle.shared.monthItemHeaderHeight) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let cell = self.cellForItem(at: self.currentMonthIndex) { + self.setContentOffset(CGPoint(x: 0.0 , y: cell.frame.origin.y - offsetY), animated: false) + } + } + } + } +} diff --git a/Pai/Sources/Views/MonthHeaderView.swift b/Pai/Sources/Views/MonthHeaderView.swift new file mode 100644 index 0000000..c8be442 --- /dev/null +++ b/Pai/Sources/Views/MonthHeaderView.swift @@ -0,0 +1,33 @@ +// +// MonthHeaderView.swift +// Pai +// +// Created by Luqman Fauzi on 26/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import UIKit + +final class MonthHeaderView: UICollectionReusableView { + + private lazy var symbolLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.isUserInteractionEnabled = true + label.font = PaiStyle.shared.monthItemHeaderFont + label.textAlignment = PaiStyle.shared.monthItemHeaderTextAlignment + label.textColor = PaiStyle.shared.monthItemHeaderTextColor + return label + }() + + override func layoutSubviews() { + super.layoutSubviews() + backgroundColor = PaiStyle.shared.monthItemHeaderBackgroundColor + symbolLabel.frame = UIEdgeInsetsInsetRect(bounds, UIEdgeInsets(top: 0, left: 15.0, bottom: 0, right: 15.0)) + addSubview(symbolLabel) + } + + public func configure(monthSymbol: String, year: Int) { + symbolLabel.text = monthSymbol + " " + year.description + } +} diff --git a/Pai/Sources/Views/MonthVerticalFlowLayout.swift b/Pai/Sources/Views/MonthVerticalFlowLayout.swift new file mode 100644 index 0000000..110a6da --- /dev/null +++ b/Pai/Sources/Views/MonthVerticalFlowLayout.swift @@ -0,0 +1,30 @@ +// +// MonthVerticalFlowLayout.swift +// Pai +// +// Created by Luqman Fauzi on 26/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import Foundation + +final class MonthVerticalFlowLayout: UICollectionViewFlowLayout { + + private var calendar = PaiCalendar.current + + override init() { + super.init() + minimumInteritemSpacing = 0 + minimumLineSpacing = 0 + sectionHeadersPinToVisibleBounds = PaiStyle.shared.monthItemHeaderShouldPin + + sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: PaiStyle.shared.monthItemPadding, right: 0) + + let width: CGFloat = UIScreen.main.bounds.width + headerReferenceSize = CGSize(width: width, height: PaiStyle.shared.monthItemHeaderHeight) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } +} diff --git a/Pai/Sources/Views/MonthViewCell.swift b/Pai/Sources/Views/MonthViewCell.swift new file mode 100644 index 0000000..2cfed96 --- /dev/null +++ b/Pai/Sources/Views/MonthViewCell.swift @@ -0,0 +1,55 @@ +// +// MonthViewCell.swift +// Pai +// +// Created by Luqman Fauzi on 26/12/2017. +// Copyright © 2017 Luqman Fauzi. All rights reserved. +// + +import UIKit + +final class MonthViewCell: UICollectionViewCell { + + private lazy var dayCollectionView: DayCollectionView = { + let collectionView = DayCollectionView() + return collectionView + }() + + private lazy var weekdaySymbolLabels: [UILabel] = { + var labels: [UILabel] = [] + for symbol in PaiCalendar.current.veryShortWeekdaySymbols { + let label = UILabel() + label.font = PaiStyle.shared.dateItemSymbolFont + label.textColor = PaiStyle.shared.dateItemSymbolTextColor + label.backgroundColor = PaiStyle.shared.dateItemBackgroundColor + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.isUserInteractionEnabled = true + label.text = symbol + labels.append(label) + } + return labels + }() + + override func layoutSubviews() { + super.layoutSubviews() + + let weekdaySymbolHeight: CGFloat = 25.0 + let weekdaySymbolWidth: CGFloat = bounds.width / CGFloat(weekdaySymbolLabels.count) + weekdaySymbolLabels.enumerated().forEach { (index, label) in + let originX: CGFloat = weekdaySymbolWidth * CGFloat(index) + label.frame = CGRect(x: originX, y: 0, width: weekdaySymbolWidth, height: weekdaySymbolHeight) + label.backgroundColor = .white + addSubview(label) + } + dayCollectionView.frame = UIEdgeInsetsInsetRect(bounds, UIEdgeInsets(top: weekdaySymbolHeight, left: 0, bottom: 0, right: 0)) + addSubview(dayCollectionView) + } + + /// Configure all events for a single particuar month & year. + /// + /// - Parameter events: `MonthlyEventsItem`, which contains all events in specific month & year. + public func configure(eventsItem: MonthlyEventsItem) { + dayCollectionView.configure(eventsItem.month, eventsItem.events) + } +} diff --git a/Pai/Supporting Files/Info.plist b/Pai/Supporting Files/Info.plist index 1007fd9..d725449 100644 --- a/Pai/Supporting Files/Info.plist +++ b/Pai/Supporting Files/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.0 + 0.1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/README.md b/README.md index fc5819b..94ae3ee 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,48 @@ alt="Contributions Welcome"> +
+ +
+ ## Requirements +**iOS 9** or later + +## Installation +### [CocoaPods](https://cocoapods.org/) +To integrate Pai using CocoaPods, add the following to your Podfile: +````ruby +pod 'Pai' +```` +### [Carthage](https://cocoapods.org/) +To integrate Pai using Carthage, add the following to your Cartfile: +````ruby +github 'lkmfz/Pai' +```` +Run `carthage update` to build the framework and drag the built `Ubud.framework` into your Xcode project. + +## Usage + +### PaiCalendarDataSource +```swift +// MARK: - PaiCalendarDataSource + +func calendarDateEvents(in calendar: MonthCollectionView) -> [PaiDateEvent] { + return events +} +``` + +### PaiCalendarDelegate +```swift +// MARK: - PaiCalendarDelegate -- **iOS 9** or later +func calendarDateDidSelect(in calendar: MonthCollectionView, at index: Int, date: PaiDate) { + /// Do anything on selected date. +} +func calendarMonthViewDidScroll(in calendar: MonthCollectionView, at index: Int, month: String, year: String) { + /// Do anything scrolling the monthly view and changing the top month content. +} +``` ## License Pai is released under the [MIT License](https://github.com/lkmfz/Pai/blob/master/LICENSE.md). \ No newline at end of file diff --git a/Resources/screenshot.png b/Resources/screenshot.png new file mode 100644 index 0000000..39e1070 Binary files /dev/null and b/Resources/screenshot.png differ