手记

用c/c++混合编程方式为ios/android实现一个自绘日期选择控件(二C)

CalendarView字符串居中对齐绘制函数:
-(void) drawStringInRectWithSize : (NSString*) string rect:(CGRect)rect size:(CGSize) size color : (UIColor*) color
{
    CGPoint pos;
    //下面算法是让文字位于要绘制的Rect的水平和垂直中心
    //也就是剧中对齐
    pos.x = (rect.size.width - size.width) * 0.5F;
    pos.y = (rect.size.height - size.height) * 0.5F;
    pos.x += rect.origin.x;
    pos.y += rect.origin.y;

    //由于周日和周六与平常文字颜色有差别,因此需要color
    NSDictionary * attsDict = [NSDictionary dictionaryWithObjectsAndKeys:
                               color, NSForegroundColorAttributeName,
                               nil ];

    [string drawAtPoint:pos withAttributes:attsDict];
}
CalendarView shape绘制函数:
    1) opengles API 利用gpu加速,速度最快,难度相对最大,自由度也最高,需要创建专用的GL上下文环境。基于状态机模式,需要设置各种绘制状态以及恢复状态。最重要的是跨平台,android以及windows,Linux都可以用(cocos2d-x基于opengles)。
    2) quartz API 使用cpu光栅化,不需要GL上下文环境,直接可在控件表面进行绘制,相对底层,基于状态机模式,需要设置各种绘制状态以及恢复状态
    3) UIKit中对quartz API的二次封装,例如UIBezierPath类,封装了大部分的shape,方便易用,我们就用这个类来进行绘制。上面两种API,以后有机会我们可以专门来分析一下。

圆的贝塞尔路径对象(由圆心和半径定义):

-(void) drawCircleInRect : (CGRect) rect color : (UIColor*) color isFill : (BOOL) isFill
{
    //取width和height最小的值作为要绘制的圆的直径,这样就不会将圆绘制范围超出rect
    float radiu = rect.size.width < rect.size.height ? rect.size.width : rect.size.height;

    //将圆的中心点从rect的左上角平移到rect的中心点
    CGPoint center;
    center.x = rect.origin.x  + rect.size.width * 0.5F;
    center.y = rect.origin. y + rect.size.height * 0.5F;
    //圆是由圆心和半径定义的
    radiu *= 0.5F;

    //创建一个圆的bezier路径对象
    UIBezierPath* circle = [UIBezierPath bezierPathWithArcCenter:center radius:radiu startAngle:0.0F endAngle:2.0F*3.1415926F clockwise:true];

    //填充绘制(日期选中状态)
    if(isFill == YES)
    {
        [color setFill];
        [circle fill];
    }
    else
    {
        //没选中状态,用stroke方式绘制
        [color setStroke];
        [circle stroke];
    }
}

圆角矩形的贝塞尔对象(由Rect和半径定义):

-(void) drawRoundRect : (CGRect) rect  radius : (CGFloat)radius
{
    UIBezierPath* roundRect = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius];
    [[UIColor colorWithRed:52/255.0 green:175/255.0 blue:248/255.0 alpha:1.0] setFill];
    [roundRect fill];
}

override drawRect函数,接管所有绘图:

代码很长,我们拆分成几个区块来分析

//1、见上图,绘制年月信息

    //blf:获取原生绘图context指针,所有原生绘图api都是c语言api方式

    //CGContextRef context = UIGraphicsGetCurrentContext();

    CGRect rc;
    calendar_get_year_month_section_rect(&_calendar, &rc);
    //NSString* drawStr = @" " + _calendar.date.year + @"年" + _calendar.date.month + @"月";
    NSString* drawStr = [NSString stringWithFormat:@"%d年%d月",_calendar.date.year,_calendar.date.month];

    //绘制年月信息
    [self drawYearMonthStr:drawStr rect:rc];
//2、见上图,绘制星期信息

    //_dayAndWeekStringArray中31-37索引保存的是星期字符串
     for(int i= 0; i < 7; i++)
    {
        //获取星期区块中某个cell的rect
        calendar_get_week_cell_rect(&_calendar, &rc, i);
        if(i == 0  i == 6)
        {
            //双休日黑色
            [self drawStringInRectWithSize:[_dayAndWeekStringArray objectAtIndex:31 + i] rect:rc size:_weekStringDrawingSize color: [UIColor blackColor]];
        }
        else
        {
            //其他时间蓝色
            [self drawStringInRectWithSize:[_dayAndWeekStringArray objectAtIndex:31 + i] rect:rc size:_weekStringDrawingSize  color: [UIColor blueColor]];
        }
    }
//3、见上图红色边框部分,绘制上个月日期信息
    CGPoint dayRectOffset;
    //获取日期区块的rect
    calendar_get_day_section_rect(&_calendar, &rc);
    //纪录日期区块的起始位置
    dayRectOffset = rc.origin;

    //当前月份1号在日期cells中的起始索引号
    int begin = _calendar.dayBeginIdx;
    //当前月份结束索引号
    int end   = begin + _calendar.dayCount;

    //绘制上个月的日期,假设begin = 5 i=[4,3,2,1,0]
    for(int i = begin - 1; i >= 0; i--)
    {
        calendar_get_day_cell_rect_by_index(&_calendar, &rc, i);

        //计算出位置偏移量
        rc.origin.x += dayRectOffset.x;
        rc.origin.y += dayRectOffset.y;

        //缩小一下绘制rect的尺寸而已
        rc.origin.x += 5;
        rc.origin.y += 5;

        rc.size.width -= 10;
        rc.size.height -= 10;

        //绘制圆圈
        [self drawCircleInRect:rc color:[UIColor colorWithRed:245/255.0 green:245/255.0 blue:245/255.0 alpha:1.0] isFill:YES];

        //计算方式涉及到了_lastMonthDayCount
        //假设上个月有30天,本月的begin为5,则
        //则30-(5-4)= 29 ---->0base--->30号
        //  30- (5-3)= 28 ---->0base--->29号
        //  30- (5-2)= 27 ---->0base--->28号
        //  30- (5-1)= 26 ---->0base--->27号
        //  30- (5-0)= 25 ---->0base--->26号
        int dayIdx = _lastMonthDayCount - (begin - i);

        //绘制圆圈中的日期
        [self drawStringInRectWithSize:[_dayAndWeekStringArray objectAtIndex: dayIdx] rect:rc size:_dayStringDrawingSize color:[UIColor colorWithRed:223/255.0 green:223/255.0 blue:223/255.0 alpha:1.0]];
    }
//4、见上图红色边框部分,绘制下个月日期信息
    for(int i = end; i < 42; i++)
    {
        calendar_get_day_cell_rect_by_index(&_calendar, &rc, i);
        rc.origin.x += dayRectOffset.x;
        rc.origin.y += dayRectOffset.y;

        rc.origin.x += 5;
        rc.origin.y += 5;

        rc.size.width -= 10;
        rc.size.height -= 10;

        [self drawCircleInRect:rc color:[UIColor colorWithRed:245/255.0 green:245/255.0 blue:245/255.0 alpha:1.0] isFill:YES];

        //索引是i-end,很容易理解的
        [self drawStringInRectWithSize:[_dayAndWeekStringArray objectAtIndex: i - end] rect:rc size:_dayStringDrawingSize color:[UIColor colorWithRed:223/255.0 green:223/255.0 blue:223/255.0 alpha:1.0]];

    }

当前月份的绘制分为选中状态的日期绘制和非选中状态日期的绘制,上图是选中状态绘制的说明图
typedef struct _selectRange
{
    int      rowIdx; //为了方便处理是否同一行
    int      columIdx;//行列转换一纬数组索引
    CGRect   rect; //纪录要绘制的rect
} selectRange;
//5、绘制当前月份的日期,包括选中,未选中以及日期文字

    //使用c结构,并初始化相关变量
    selectRange  ranges[31];
    memset(ranges,0,sizeof(ranges));
    int          rangeCount = 0;

    //绘制当前月的日期
    for(int i = begin ;  i < end; i++)
    {
        calendar_get_day_cell_rect_by_index(&_calendar, &rc, i);
        rc.origin.x += dayRectOffset.x;
        rc.origin.y += dayRectOffset.y;

        rc.origin.x += 5;
        rc.origin.y += 5;

        rc.size.width -= 10;
        rc.size.height -= 10;

        SDate date;
        date_set(&date, _calendar.date.year, _calendar.date.month, i - begin + 1 );

        //如果当前日期在选中时间范围内,则batch起来,由drawSelectRange进行绘制
        //因为需要处理换行这种效果(drawSelectRange中处理,因此缓存起来二次处理比较方便)
        //与delegate通信
        if([self.calendarDelegate isInSelectedDateRange:date])
        {

            ranges[rangeCount].rowIdx = i / 7; //映射成行索引
            ranges[rangeCount].columIdx = i % 7; //映射成列索引
            ranges[rangeCount].rect = rc; //当前行列的rect纪录下来
            rangeCount++; //计数器增加1
        }
        else
        {

            //没有选中的,就直接绘制圆圈和当中的日期号
            [self drawCircleInRect:rc color:[UIColor colorWithRed:234/255.0 green:234/255.0 blue:234/255.0 alpha:1.0] isFill:NO];
            [self drawStringInRectWithSize:[_dayAndWeekStringArray objectAtIndex: i - _calendar.dayBeginIdx] rect:rc size:_dayStringDrawingSize color:[UIColor colorWithRed:107/255.0 green:107/255.0 blue:107/255.0 alpha:1.0]];
        }
    }

    //NSLog(@"select day count = %d",rangeCount);
    //rangeCount纪录了选中的数量,ranges则纪录了要绘制的所有信息
    [self drawSelectRange:ranges count:rangeCount];

    //选中的圈圈的文字由下面代码绘制
    for(int i = 0; i < rangeCount; i++)
    {
        //重新将行列(二维)索引号映射一纬数组索引号
        int idx = ranges[i].rowIdx * 7 + ranges[i].columIdx;
        //idx - begin就是当前的要绘制的日期文字的索引号
        [self drawStringInRectWithSize:[_dayAndWeekStringArray objectAtIndex: idx - begin] rect:ranges[i].rect size:_dayStringDrawingSize  color:[UIColor whiteColor]];
    }
关键的drawSelectRange函数:
//blf:注意 参数ranges是数组名,数组名表示数组的首地址
//         还有就是selectRange是c结构,当做指针操作时要用->而不是.寻址操作符
-(void) drawSelectRange : (selectRange* ) ranges count : (int) count
{
    //两种情况下count = 1
    //第一选则,或者第二次选中的和第一次选中的是同一个日期cell
    //此时是绘制圆形而不是roundedRect
    if(count == 1)
    {
        [self drawCircleInRect : ranges[0].rect color:[UIColor colorWithRed:52/255.0 green:175/255.0 blue:248/255.0 alpha:1.0] isFill:YES];

        //退出函数
        return;
    }

    //并不是第一次选者且第二次选者不是和第一次选者一致时

    //获取cell rect的width
    CGRect rect;
    calendar_get_day_cell_rect_by_index(&_calendar, &rect, 0);
    float width = rect.size.width;

    //用于纪录上一次的行号,初始化,纪录的是第一行的索引号
    int lastRowIdx = ranges[0].rowIdx;
    //计数器,用来纪录当前行的cell的数量
    int sameRowCellCount = 0;

    for(int i = 0; i < count; i++)
    {
        //从ranges数组中获取一个结构时候,使用了&取地址操作符
        //因为防止发生拷贝,如果不是取地址的话,赋值会发生memcopy行为
        selectRange* range = &ranges[i];
        //行号相同,则同一行啦
        if(range->rowIdx == lastRowIdx)
        {
            sameRowCellCount++;
        }
        else
        {
            //行号不同,说明换行了,因此要绘制当前行
            CGRect rc;
            //i - sameRowCellCount找到起始索引
            rc.origin = ranges[i - sameRowCellCount].rect.origin;
            rc.size.height = range->rect.size.height;
            rc.size.width = width* (sameRowCellCount) - 10.0F;

            //很可能存在这种情况,既选中的是周六开始的,因此绘制的是圆形而不是roundedRect
            if(sameRowCellCount == 1)
            {
                [self drawCircleInRect:rc color:[UIColor colorWithRed:52/255.0 green:175/255.0 blue:248/255.0 alpha:1.0] isFill:YES];
            }
            else
            {
                //一般情况,绘制roundedRect
                [self drawRoundRect:rc radius:rc.size.height];
            }

            sameRowCellCount = 1;//标记值,为了下面绘制最后一行的代码使用,=1和>1要分别处理

            //纪录上一次的行号
            lastRowIdx = range->rowIdx;
        }
    }

    //将最后一行拆分出来单独处理,这样就方便处理一些特殊情况

    //绘制最后一行
    if(sameRowCellCount > 0)
    {
        CGRect rc;
        rc.origin = ranges[count - sameRowCellCount].rect.origin;
        rc.size.height = ranges[count - sameRowCellCount].rect.size.height;
        rc.size.width  = width* (sameRowCellCount) - 10.0F;

        //最后一行有多个cell被选中
        if(sameRowCellCount != 1)
        {
            [self drawRoundRect:rc radius:rc.size.height];
        }
        else//最后一行仅周日被选中,只有一个,圆圈
            [self drawCircleInRect:rc color:[UIColor colorWithRed:52/255.0 green:175/255.0 blue:248/255.0 alpha:1.0] isFill:YES];

    }
}
至此,CalendarView的绘图部分代码全部完毕,我们来看看与delegate通信的选中判断函数:
//由于UITableView采用了cell重用机制,因此仅有很屏幕rect相交的cell存在
//所以是cells一直轮替交换,所以我们必须在每次自绘时候判断当前的cell中的月历的每个日期是否处于选中状态
//而本函数就是起到这样的作用,判断月历中某个日期是否处于选中的区间范围
-(BOOL)isInSelectedDateRange : (SDate) date
{
    time_t curr = date_get_time_t(&date);

    if(curr < _startTime  curr > _endTime)
        return NO;

    return YES;
}
控件开发,不管是IOS,android还是windows,万流归宗,归根到底就是做4件事情:
   控件的状态初始化  
   控件的绘制  
   控件的事件触发和处理  
   控件的布局
接下来我们看看如何处理CalendarView的触摸事件:
-(void) handleTouchEvent:(id) sender forEvent:(UIEvent *)event
{
    NSSet *touches = [event allTouches];
    UITouch *touch = [touches anyObject];

    //获取UITouch,将其转换到当前CalendarView的局部坐标系表示
    CGPoint upLoc = [touch locationInView:self];

    //通过局部坐标系的点获取点击处的cell的索引号,优化部分请看c的相关实现
    //这个碰撞检测原理实际在游戏中经常使用,分区缩小范围,然后检测该范围内所有物体的与点(2D)
    //或光线(3D)是否发生碰撞,用于此处也非常适合
    int hitIdx = calendar_get_hitted_day_cell_index(&_calendar, upLoc);

    //选中了,则
    if(hitIdx != -1)
    {
        SDate date;
        date_set(&date, _calendar.date.year, _calendar.date.month, hitIdx - _calendar.dayBeginIdx + 1);

        //=0为第一次点击,仅选中一个cell
        //mod为了周而复始,并在[0,1]之间
        if([self.calendarDelegate getHitCounter] % 2 == 0)
        {
            //第一次点击,让开始和结束Date相同
            [self.calendarDelegate setSelectedDateRangeStart:date end:date];
        }
        else//=1为第二次点击,形成选区
        {
            [self.calendarDelegate setEndSelectedDate:date];
        }

        //每次点击,delegate中的点击计数器都要递增的
        [self.calendarDelegate updateHitCounter];

        //需要触发重绘,让ios进行重新绘制,这个很关键,有一些细节,在下面会说明的
        [self.calendarDelegate repaintCalendarViews];
    }

}
先来看一下重绘代码,然后推导一些细节:
//属于CalendarDelegate的接口函数,实现代码如下:
-(void) repaintCalendarViews
{
    //[self.tableView setNeedsDisplay];

    for(UIView * subview in self.tableView.subviews)
    {
        for(UIView* view2 in subview.subviews)
        {
            UITableViewCell* cell = (UITableViewCell*)view2;
            CalendarView* cview  =(CalendarView*) [cell.contentView.subviews objectAtIndex:0];
            [cview setNeedsDisplay];
        }
    }
}
由上面的的代码,可以了解到如何从UITable寻址到各个CalenderView: UITableView->UITableViewCell->ContentView->CalendarView->setNeedsDisplay
1) 由于calendarView的选择可能跨越多个CalendarView,因此不能仅仅在CalendarView级别setNeedsDisplay,而是需要让整个UITableView以及他的所有子孙控件都要重绘。
2) 按照正常思路,你在UITableView上调用setNeedsDisplay,你会发现无效。
3) 由此可见,IOS中的脏区局部刷新机制采用的是以控件为基础的后备缓冲图,而不是以整个屏幕为基础的后背缓冲图。
4) 以控件为基础的后备缓冲图内存消耗高,但是能够解决重复绘制,提高效率,典型的以空间换时间策略。
    介绍一个微软开源项目WinObjc,非常强大,可以在gitHub中去查找。
    为win10和Winphone实现了整个ios sdk,目的是让ios的app直接在winphone上跑。
    我研究过他整个局部刷新的机制,还是蛮帅的。
    foundation,uikit, glkit,spritekit,gamekit,homekit....各种kit都实现了。而且最重要的是有源码。
5) 以整个屏幕(或者说整个APP显示根节点的size)为大小的后备缓冲区,其只需要增加一张内存位图。
   获取脏区后,仅仅递归该脏区以及所有和父节点脏区相交部分的区域 进行更新,因此更新区域会逐渐减小,但是不能完全去除重复绘制。
   我曾经实现了opengl和dx版本的2D局部刷新机制,并入到一个2d UI引擎中,利用后背缓冲区以及
   基于修改投影矩阵方式,在光栅化之前裁剪掉所有不可见的顶点后,
   其渲染速度飞速提高,并且CPU使用率控制在5%以下,大部分时间都是
   在1%)。源码不能公布,因为是商业代码,但是demo以后可以在github上下载,很帅的IPhone4仿真模拟。
还有几个delegate中用到的协议方法:
#define MYSWAP(x,y,type)    \
{                           \
    type t = x;             \
    x = y;                  \
    y = t;                  \
}

-(void)setSelectedDateRangeStart:(SDate)start end:(SDate)end
{

    //将date转换为time_t
    _startTime = date_get_time_t(&start);
    _endTime   = date_get_time_t(&end);

    //如果起始时间大于结束时间,说明先点击后一天,再点击前一天,绘制时的逻辑不正确,需要交换一下时间
    if(_startTime > _endTime)
    {
        MYSWAP(_startTime,_endTime,time_t);

        //纪录下年月表示起始结束date
        _begDate = end;
        _endDate = start;

    }else{

        _begDate = start;//记录日期
        _endDate = end;
    }
}

-(void)setEndSelectedDate:(SDate)end
{
    //同上,只是针对第二次点击而已
    _endTime = date_get_time_t(&end);
    if(_startTime > _endTime)
    {
        MYSWAP(_startTime,_endTime,time_t);
        _endDate = _begDate;
        _begDate = end;
    }else{
        _endDate = end;
    }
}

-(void) updateHitCounter
{
    _hitCounter++;
}

-(int) getHitCounter
{
    return _hitCounter;
}

至此,IOS版本的源码全部分析完毕,希望对大家有帮助。

关于控件的布局,本DEMO中没什么用到,控件的布局可以说是比较复杂的部分,有各种算法,各种方法,是个比较大的主题,以后有机会探讨。

总体来说,apple公司的objc编译器前端程序Clang支持Objc,c,c++的词法分析,AST的产生,然后进入llvm,生成对应CPU的指令。再在IOS上运行,由于都是二进制,所以效率非常高(苹果公司不允许使用虚拟机代码方式,只能以静态链接库【二进制】方式 运行app,高效, 难以反编译,因此相对非常安全,唯一的例外是运行于浏览器中的js代码)。

IOS部分完毕,下一篇与android有关。由于Ios objc对c和c++支持非常好,所以没什么难度,但是android就很复杂了,所以我个人认为更有价值。下篇中,我们不再以代码为主,而是了解android中如何方便,高效的进行JNI交互。
对了,实际上上面的源码还可以更多的优化,大家可以建议,探讨。

源码:

https://github.com/jackyblf/CalendarView_IOS_Android/tree/master/IOS/Calendar

4人推荐
随时随地看视频
慕课网APP

热门评论

再次修正一个bug,已上传github
-(void) setYearMonth:(int)year month:(int)month
{
calendar_set_year_month(&_calendar, year, month);
if (_calendar.date.month > 1) {
_lastMonthDayCount = date_get_month_of_day(_calendar.date.year,_calendar.date.month-1);
}else{
_lastMonthDayCount = date_get_month_of_day(_calendar.date.year-1,12);
}
[self setNeedsDisplay];
}

 修正一个bug,已上传github

void date_map_index_to_year_month(SDate* to,int startYear,int idx)

{

assert(to);

to->year = startYear + idx / 12;

to->month = idx % 12 + 1;

//to->day = -1;

//修改成如下代码。应该为1而不是-1

to->day = 1;

}


查看全部评论