用JTAppleCalendarView快速设计iOS日历控件

JTAppleCalendarView是一个基于CollectionView可定制化的日历控件。虽然在实现效率上低的惊人--它是预先生成一个指定起始终止日期区间的有限长度的日期集合,再一并渲染到View上。相比于很多Android日历控件,这种设计思路还是很原始的。但是好在轮子造的还不错,可定制化也很友好,该有的功能都有了。所以不是追求性能的情况下还是可以拿来一用。

下图是一个简单的月模式的日历控件效果图。


image.png

这个控件实现了几个基本的日历效果:

  1. 显示一个月前后连续周的日期
  2. 非本月日期用暗色标注
  3. 一个日期选择标示(白色圆形)
  4. 日期标注(日期下面的白色圆点)
  5. 左右滑动
  6. 如果选择非本月日期则自动滑动到目标月份
  7. 一键返回当天
  8. 日历表头

JTAppleCalendarView的自定义化很简单,只需要设计一个CellView,这个例子使用了Xib生成的方式,目标代码如下:


import UIKit
import JTAppleCalendar
class UTKUICalendarCellView:  JTAppleCell{
    @IBOutlet weak var selectNoter: UTKUICircleBgView!
    @IBOutlet weak var dayLabel: UILabel!
    @IBOutlet weak var marker: UTKUICircleBgView!
    
    var date:Date? //当前cell的日期
    var isMarked = false //当前cell是否包含标记
    var isChosen = false //cell是否被选中
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
        
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    //使用一个通用的init来加载xib
    func commonInit() {
        let nib = UINib(nibName: "UTKUICalendarCell", bundle: Bundle.main);
        let view = nib.instantiate(withOwner: self, options: nil)[0] as! UIView
        //自动匹配父控件的长宽
        view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.addSubview(view)
        marker.alpha = 0
    }
    
    func setDate(date:Date) {
        self.date = date
    }
    
    //选中时的动画效果
    func setSelected() {
        self.isChosen = true
        UIView.animate(withDuration: 0.2) {
            self.selectNoter.transform = CGAffineTransform(scaleX: 1, y: 1)
            let colorSelected = UIColor(red: 118/255.0, green: 204/255.0, blue: 211/255.0, alpha: 1)
            self.dayLabel.textColor = colorSelected
        }
    }
    
    //未选中时的动画效果
    func setUnSelected(isThisMonth:Bool) {
        self.isChosen = false
        //        UIView.animate(withDuration: 0.8) {
        self.selectNoter.transform = CGAffineTransform(scaleX: 0, y: 0)
        
        if isThisMonth {
            self.dayLabel.textColor = UIColor.white
        }
        else {
            self.dayLabel.textColor = UIColor(red: 102/255.0, green: 176/255.0, blue: 170/255.0, alpha: 1)
        }

        
        //        }
    }
    
  //标记的动画效果
    func setMarked() {
        self.isMarked = true
        UIView.animate(withDuration: 0.4) {
            self.marker.alpha = 1
        }
    }

  //无标记的动画效果    
    func setUnmarked() {
        self.marker.alpha = 0
        self.isMarked = false
    }
    
}

整体上和普通的CollectionCellView一致

本例采用了一个简单的UIView拓展组件,大多数文章内谈论的是calendardelegate和datesource的设置,这里我们重点注释一下它的其他设置。

import UIKit
import JTAppleCalendar

//回调
protocol UTKUICalendarViewDelegate {
    func onYearChanged(year:Int)
    func onMonthChanged(month:Int)
    func onToMarkDate(date:Date)
    func onToDate(date:Date)
}

class UTKUICalendarView: UIView {

    var delegate:UTKUICalendarViewDelegate?
    let cellReuseId = "UTKUICalendar"
    var markedDate:[Date] = []
    var selectedDate = Date()
    var today = Date()
    

    var inDay = 1
    var inMonth = 1
    var inYear = 2014
    
    let dateFormatter = DateFormatter()
    var calendar:JTAppleCalendarView?
    var calendarHeader:UIView?
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder);
        dateFormatter.dateFormat = "yyyy MM dd"
        dateFormatter.timeZone = Calendar.current.timeZone
        dateFormatter.locale = Calendar.current.locale
        let strs = dateFormatter.string(from: today).split(separator: " ")
        inMonth = Int(strs[1])!
        inYear = Int(strs[0])!
        
        if delegate != nil {
            delegate?.onYearChanged(year: inYear)
            delegate?.onMonthChanged(month: inMonth)
        }
        
        setup()
    }
    
    var isInited = false
    
    //initiating calendar and header components
    func setup() {
        let headerXib = UINib(nibName: "UTKUICalendarHeader", bundle: Bundle.main)
        calendarHeader = headerXib.instantiate(withOwner: self, options: nil)[0] as? UIView
        
        
        calendar = JTAppleCalendarView(frame: CGRect(x: 0, y: 40, width: frame.width, height: frame.height-40))
       
        //设置JTAppleCalendar的属性
        setupCalendar()
        //设置完毕之后默认初始选中日期是当天
        backToday()
    }
    
    /*不要在这里设置calendar的属性,多次调用会引起错误*/
    override func layoutSubviews() {
        super.layoutSubviews()
        calendarHeader?.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: 40)
        self.addSubview(calendarHeader!)
        calendar?.frame = CGRect(x: 0, y: 40, width: frame.width, height: frame.height-40)
        self.addSubview(calendar!)
    }

    func setupCalendar() {
        
    //首先注册cellview
        calendar?.register(UTKUICalendarCellView.self, forCellWithReuseIdentifier: self.cellReuseId)
    //然后注册delegate和datasource
        calendar?.calendarDataSource = self
        calendar?.calendarDelegate = self
    //设置日历的一些基本格式,这里我们把所有的空全部去除
        calendar?.minimumLineSpacing = 0
        calendar?.minimumInteritemSpacing = 0
  //这里设置为按月份滑动,另一种滑动模式是连续滑动,不按月份停留
        calendar?.scrollingMode = ScrollingMode.stopAtEachCalendarFrame
//选择左右滑动
        calendar?.scrollDirection = UICollectionViewScrollDirection.horizontal
//隐藏所有的滑动条
        calendar?.showsVerticalScrollIndicator = false
        calendar?.showsHorizontalScrollIndicator = false
        calendar?.backgroundColor = UIColor(red: 118/255.0, green: 204/255.0, blue: 211/255.0, alpha: 1)
    }
    
  //返回当天
    func backToday() -> Bool {
        today = Date()
        var ret = false
        if dateFormatter.string(from: today) == dateFormatter.string(from: selectedDate) {
            ret = false
        }
        else {
            ret = true
        }
    //在这里只需要设置selectDates就会自动调用后面的didSelectedDate的delegate,建议在完成滚动之后再设置,否则容易出现cell被回收的问题
        calendar?.scrollToDate(today, triggerScrollToDateDelegate: true, animateScroll: true, completionHandler: {
            self.calendar?.selectDates([self.today])
            self.selectedDate = self.today
//如果不放心可以调用下面这个函数重置所有的格子状态
//                        self.calendar?.reloadData() //failsafe solution to avoid nil cell deselect
        })  
        return ret
    }
    
    func isThisMonth(date:Date) -> Int64 {
        let formatter = DateFormatter()
        formatter.dateFormat = "MM"
        let month = Int(formatter.string(from: date))
        formatter.dateFormat = "yyyy"
        let year = Int(formatter.string(from: date))
        if month! < inMonth && year! > inYear {
            return 1 //next month
        }
        else if month! > inMonth && year! < inYear{
            return -1 //previous month
        }
        else {
            if month! < inMonth {
                return -1
            }
            else if month! > inMonth {
                return 1
            }
            else {
                return 0
            }
        }
    }
}

extension UTKUICalendarView:JTAppleCalendarViewDelegate {
      //7.1版本之后作者建议调用WillDisplay,否则容易出现问题
    func calendar(_ calendar: JTAppleCalendarView, willDisplay cell: JTAppleCell, forItemAt date: Date, cellState: CellState, indexPath: IndexPath) {
        let dcell = cell as! UTKUICalendarCellView
        

      //configCell配置cell的状态,这个与下面一个函数调用的过程是一样的
        configCell(dateCell: dcell, date: date, label: cellState.text, state:cellState)
        
    }
    
    func calendar(_ calendar: JTAppleCalendarView, cellForItemAt date: Date, cellState: CellState, indexPath: IndexPath) -> JTAppleCell {
        let cell = calendar.dequeueReusableJTAppleCell(withReuseIdentifier: self.cellReuseId, for: indexPath) as! UTKUICalendarCellView
        
        configCell(dateCell: cell, date:date, label:cellState.text, state:cellState)
        return cell
    }
    
    func configCell(dateCell:UTKUICalendarCellView, date:Date, label:String, state:CellState) {
        dateCell.dayLabel.text = label
        dateCell.setDate(date: date)
        
        if markedDate.contains(date) {
            dateCell.setMarked()
        }
        else {
            dateCell.setUnmarked()
        }
        
        if (calendar?.selectedDates.count)! > 0 {
    //判断当前日期是否被选中,可以采用下面两种方法
//            if state.isSelected {
             if dateFormatter.string(from: date) == dateFormatter.string(from:selectedDate) {
                dateCell.setSelected()
            }
            else {
                dateCell.setUnSelected(isThisMonth:state.dateBelongsTo == .thisMonth)
                if state.dateBelongsTo != .thisMonth {

                }
            }
        }
        else {
            dateCell.setUnSelected(isThisMonth: state.dateBelongsTo == .thisMonth)
        }
        
       
    }
    
    func calendar(_ calendar: JTAppleCalendarView, didSelectDate date: Date, cell: JTAppleCell?, cellState: CellState) {
        
        if dateFormatter.string(from:date) != dateFormatter.string(from: selectedDate) {
            selectedDate = date
        //只有设置了selectDates后续的刷新才会正常
            self.calendar?.selectDates([date])
            
            let dateCell = cell as? UTKUICalendarCellView
            if dateCell != nil {
                //这里需要手动更新cell的view状态
                dateCell?.setSelected()
                if (dateCell?.isMarked)! && delegate != nil {
                    delegate?.onToMarkDate(date: date)
                }
            }

//下面的代码判断是否选中了其他月份的日期,并自动滑动           
            let res = isThisMonth(date: date)
            if res == 1 {
                

self.calendar?.scrollToSegment(SegmentDestination.next)
            }
            else if res == -1 {
                self.calendar?.scrollToSegment(SegmentDestination.previous)
            }
            
            if delegate != nil {
                delegate?.onToDate(date: date)
            }
        }
    }
    
    func calendar(_ calendar: JTAppleCalendarView, didDeselectDate date: Date, cell: JTAppleCell?, cellState: CellState) {
        let dateCell = cell as? UTKUICalendarCellView
        dateCell?.setUnSelected(isThisMonth: cellState.dateBelongsTo == .thisMonth)        
    }
    
    //当滑动到新的月份时触发这个delegate,可以通过回调对外刷新年份与月份
    func calendar(_ calendar: JTAppleCalendarView, didScrollToDateSegmentWith visibleDates: DateSegmentInfo) {
        let inDate = visibleDates.monthDates[0].date
        let str = dateFormatter.string(from: inDate)
        let strs = str.split(separator: " ")
        let year = Int(strs[0])!
        let month = Int(strs[1])!
        
        if delegate != nil {
            if (month != inMonth) {
                inMonth = month
                delegate?.onMonthChanged(month: inMonth)
            }
            
            if (year != inYear) {
                inYear = year
                delegate?.onYearChanged(year: inYear)
            }
        }
    }
    
}

extension UTKUICalendarView:JTAppleCalendarViewDataSource {
    func configureCalendar(_ calendar: JTAppleCalendarView) -> ConfigurationParameters {
        //在这里设置起始终止时间,时间长度越短加载越快
        let startTime = dateFormatter.date(from: "2014 01 01")
        let stopTime = dateFormatter.date(from: "2030 12 31")
        let params = ConfigurationParameters(startDate: startTime!, endDate: stopTime!, numberOfRows: 6, calendar: Calendar.current, generateInDates: InDateCellGeneration.forAllMonths, generateOutDates: OutDateCellGeneration.tillEndOfGrid, firstDayOfWeek: .monday, hasStrictBoundaries: false)
        return params
    }
    
    
}

总体而言,JTAppleCalendarView的配置还是很简单的。CollectionView大部分的问题都会出现在当Cell被回收的时候带来的状态错乱。因此尽量使用控件自有的selectDates,CellState。此外,Calendar的高度会根据配置的参数调用layoutsubviews进行多次调整,因此大部分的设置与状态初始化需要避免在这个方法内进行。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容