iOS 实战 - Infinite Scrolling

以上动画展示了应用 每日体重记录 中通过尺子来输入体重,要实现尺子的输入控件需要通过继承 UIScrollView 来做 Infinite Scrolling。
尺子中的刻度
首先我们需要通过继承 UIView 来实现上图中的刻度块,一个刻度块包含十个刻度和一个刻度值,不需要使用图片,通过 Quartz 框架来绘制,代码如下:
extern const CGFloat DTRulerScaleBlockWidth;
extern const CGFloat DTRulerScaleGap;
@interface DTRulerViewScale : UIView
@property (nonatomic) int weight;
- (id)initWithWeight:(int)weight frameHeight:(CGFloat)height;
@end
#import "DTRulerViewScale.h"
#import "DTThemeManager.h"
#import "DTTheme.h"
const CGFloat DTRulerScaleBlockWidth = 100;
const CGFloat DTRulerScaleGap = 10;
@implementation DTRulerViewScale
- (id)initWithWeight:(int)weight frameHeight:(CGFloat)height {
self = [super initWithFrame:CGRectMake(0.0, 0.0, DTRulerScaleBlockWidth, height)];
if (self) {
self.backgroundColor = [[DTThemeManager sharedInstance] currentTheme].temporaryViewBackgroundColor;
_weight = weight;
}
return self;
}
- (void)setWeight:(int)weight {
if (_weight != weight) {
_weight = weight;
[self setNeedsDisplay];
}
}
- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();
[self doScaleWithWeight:self.weight rect:rect context:context];
}
- (void)doScaleWithWeight:(int)weight rect:(CGRect)rect context:(CGContextRef)context {
CGFloat startX = CGRectGetMidX(rect) + DTRulerScaleGap / 2;
CGFloat startY = CGRectGetMinY(rect);
[[[DTThemeManager sharedInstance] currentTheme].temporaryViewTextColor setStroke];
NSString *weightLabel = [NSString stringWithFormat:@"%d", weight];
NSDictionary *attributes = @{NSFontAttributeName: [[DTThemeManager sharedInstance] rulerScaleTextFont],
NSForegroundColorAttributeName: [[DTThemeManager sharedInstance] currentTheme].temporaryViewTextColor};
CGSize textSize = [weightLabel sizeWithAttributes:attributes];
[weightLabel drawInRect:CGRectMake(startX - textSize.width / 2, startY + self.frame.size.height * 0.46875 + 4, textSize.width, textSize.height)
withAttributes:attributes];
CGContextSetLineWidth(context, 2.0);
CGContextMoveToPoint(context, startX, startY);
CGContextAddLineToPoint(context, startX, self.frame.size.height * 0.46875);
CGContextDrawPath(context, kCGPathStroke);
[self doScaleFromMidToEgdeWithStartX:startX startY:startY counter:4 plus:YES context:context];
[self doScaleFromMidToEgdeWithStartX:startX startY:startY counter:5 plus:NO context:context];
}
- (void)doScaleFromMidToEgdeWithStartX:(CGFloat)startX startY:(CGFloat)startY counter:(int)counter plus:(BOOL)plus context:(CGContextRef)context {
CGContextSetLineWidth(context, 1);
for (int i = 0; i < counter; i++) {
if (plus) {
startX += DTRulerScaleGap;
} else {
startX -= DTRulerScaleGap;
}
CGContextMoveToPoint(context, startX, startY);
CGContextAddLineToPoint(context, startX, self.frame.size.height * 0.3125);
CGContextDrawPath(context, kCGPathStroke);
}
}
@end
UIScrollView Infinite Scrolling
我们已经实现了尺子的刻度块,刻度块的长度为 100 点,如果以 iPhone 4s 的宽度,用户能够看到的刻度块也就是 3 个多一点,所以我们设定由 5 个刻度块首尾相接组成一把尺子,代码如下:
@interface DTRulerView : UIScrollView
- (instancetype)initWithFrame:(CGRect)frame weight:(float)weight;
@end
#import "DTRulerView.h"
#import "DTRulerView.h"
#import "DTRulerViewScale.h"
static const int DTRulerScaleBlockNumber = 5;
static const int DTRulerMinScale = 9;
static const int DTRulerMaxScale = 999;
@interface DTRulerView ()
@property BOOL isAnimating;
@property (strong, nonatomic) NSMutableArray *weights;
@property (strong, nonatomic) NSMutableArray *rulerScales;
@property (strong, nonatomic) NSMutableArray *reusedRulerScales;
@property (strong, nonatomic) UIView *container;
@end
@implementation DTRulerView
- (instancetype)initWithFrame:(CGRect)frame weight:(float)weight {
self = [super initWithFrame:frame]; // frame is necessary for caculating scale position
if (self) {
self.contentSize = CGSizeMake(DTRulerScaleBlockWidth * DTRulerScaleBlockNumber, self.frame.size.height);
self.showsHorizontalScrollIndicator = NO;
self.delegate = self;
_container = [[UIView alloc] init];
_container.frame = CGRectMake(0, 0, self.contentSize.width, self.frame.size.height);
_container.userInteractionEnabled = NO;
[self addSubview:_container];
_weights = [[NSMutableArray alloc] init];
_rulerScales = [[NSMutableArray alloc] init];
_reusedRulerScales = [[NSMutableArray alloc] init];
int weightInt = roundf(weight * 10);
int weightDecimal = weightInt % 10; // get weight`s decimal
weightInt = weightInt / 10;
int offsetGapNumber = 0;
if (weightDecimal <= 4) {
offsetGapNumber = weightDecimal + 5;
} else {
offsetGapNumber = weightDecimal - 5;
weightInt++;
}
CGFloat startX = CGRectGetMidX(self.frame) - DTRulerScaleGap / 2; // point to middle scale block`s first scale
startX -= offsetGapNumber * DTRulerScaleGap; // first scale with right gap offset
int indexs[DTRulerScaleBlockNumber] = {0, -1, -2, 1, 2};
for (int i = 0; i < DTRulerScaleBlockNumber; i++) {
int index = indexs[i];
[self placeWeight:((index == 0) ? [NSNumber numberWithInt:weightInt] : nil)
orNewPrevious:(index < 0)
calculateFrame:^CGRect(CGRect frame) {
frame.origin.x += startX + frame.size.width * index;
return frame;
}];
}
}
return self;
}
@end
现在我们已经可以展示出尺子了,但是我们还没有实现滑动,在滑动过程中 layoutSubviews 方法会被调用,看名字就知道是用来重新布局子视图,也就是刻度块,在 layoutSubviews 方法中需要将 UIScrollView 的 Content 再次居中,并且依次移动所有刻度块保持相同的偏移,滑动过后,有的刻度块就会超出最大或最小可视区域,需要将其移除,同样需要生成新的刻度块来显示在尺子中,其原理就像 UITableView 在上下滑动的过程中不断地重用 UITablewViewCell 来显示数据,这样才能保证内存使用量不会不断上升,代码如下:
- (void)layoutSubviews{
[super layoutSubviews];
[self recenterIfNecessary];
CGRect visibleBounds = [self visibleBounds];
CGFloat minimumVisibleX = CGRectGetMinX(visibleBounds);
CGFloat maximumVisibleX = CGRectGetMaxX(visibleBounds);
[self tileChildrensFromMinX:minimumVisibleX toMaxX:maximumVisibleX];
}
- (void)recenterIfNecessary {
CGPoint currentOffset = self.contentOffset;
CGFloat contentWidth = self.contentSize.width;
CGFloat centerOffsetX = (contentWidth - self.bounds.size.width) / 2.0;
CGFloat distanceFromCenterSign = currentOffset.x - centerOffsetX;
CGFloat distanceFromCenter = fabs(distanceFromCenterSign);
if (distanceFromCenter > centerOffsetX) {
self.contentOffset = CGPointMake(centerOffsetX, currentOffset.y);
// move content by the same amount so it appears to stay still
[self moveAllChildrensWithOffsetX:-distanceFromCenterSign animation:NO];
}
}
- (void)moveAllChildrensWithOffsetX:(CGFloat)offsetX animation:(BOOL)animation{
if (animation) {
[UIView animateWithDuration:0.2 animations:^{
for (DTRulerViewScale *rulerScale in self.rulerScales) {
rulerScale.center = CGPointMake(rulerScale.center.x + offsetX, rulerScale.center.y);
}
}];
} else {
for (DTRulerViewScale *rulerScale in self.rulerScales) {
rulerScale.center = CGPointMake(rulerScale.center.x + offsetX, rulerScale.center.y);
}
}
}
- (CGRect)visibleBounds {
return [self convertRect:[self bounds] toView:self.container];
}
- (void)placeWeight:(NSNumber *)weight orNewPrevious:(BOOL)previous calculateFrame:(CGRect (^)(CGRect frame))calculateFrame {
DTRulerViewScale *rulerScale = self.reusedRulerScales.lastObject;
if (!rulerScale) {
rulerScale = [[DTRulerViewScale alloc] initWithWeight:0 frameHeight:self.frame.size.height];
}
if (!weight && !previous) {
weight = [self nextWeight];
}
if (!previous) {
[_weights addObject:weight];
[_rulerScales addObject:rulerScale];
[_container addSubview:rulerScale];
} else {
weight = [self previousWeight];
[_weights insertObject:weight atIndex:0];
[_rulerScales insertObject:rulerScale atIndex:0];
[_container insertSubview:rulerScale atIndex:0];
}
rulerScale.weight = weight.intValue;
rulerScale.frame = calculateFrame(rulerScale.frame);
}
- (NSNumber *)previousWeight {
NSNumber *weight = [self.weights objectAtIndex:0];
if (weight.intValue > DTRulerMinScale) {
return [NSNumber numberWithInt:weight.intValue - 1];
} else {
return [NSNumber numberWithInt:DTRulerMaxScale];
}
}
- (NSNumber *)nextWeight {
NSNumber *weight = self.weights.lastObject;
if (weight.intValue < DTRulerMaxScale) {
return [NSNumber numberWithInt:weight.intValue + 1];
} else {
return [NSNumber numberWithInt:DTRulerMinScale];
}
}
- (void)tileChildrensFromMinX:(CGFloat)minimumVisibleX toMaxX:(CGFloat)maximumVisibleX {
// add child that are missing on right side
DTRulerViewScale *last = self.rulerScales.lastObject;
CGFloat rightEdge = CGRectGetMaxX(last.frame);
if(rightEdge < maximumVisibleX) {
[self placeWeight:nil orNewPrevious:NO calculateFrame:^CGRect(CGRect frame) {
frame.origin.x = rightEdge;
return frame;
}];
}
// add child that are missing on left side
DTRulerViewScale *first = [self.rulerScales objectAtIndex:0];
CGFloat leftEdge = CGRectGetMinX(first.frame);
if (leftEdge > minimumVisibleX) {
[self placeWeight:nil orNewPrevious:YES calculateFrame:^CGRect(CGRect frame) {
frame.origin.x = leftEdge - frame.size.width;
return frame;
}];
}
if(self.rulerScales.count > DTRulerScaleBlockNumber){
// remove child that have fallen off right edge
last = self.rulerScales.lastObject;
leftEdge = CGRectGetMinX(last.frame);
if (last && leftEdge > maximumVisibleX) {
[self.reusedRulerScales addObject:last];
[self.weights removeLastObject];
[self.rulerScales removeLastObject];
[last removeFromSuperview];
}
// remove child that have fallen off left edge
first = [self.rulerScales objectAtIndex:0];
rightEdge = CGRectGetMaxX(first.frame);
if (first && rightEdge < minimumVisibleX) {
[self.reusedRulerScales addObject:first];
[self.weights removeObjectAtIndex:0];
[self.rulerScales removeObjectAtIndex:0];
[first removeFromSuperview];
}
}
}
用户体验在于细节
用户滑动尺子后,我们应该保证指向刻度的箭头指在刻度线上,而不是空隙上,同样的还需要将指向的刻度值显示出来,都是通过 UIScrollViewDelegate 来实现的,需要注意的是用户滑动频率比较快时,刻度值的变化就比较快,很有可能超过视图的渲染频率,所以需要 isEnoughTimeElapsed 方法来确认是不是过了足够的时间来降低改变刻度值的频率,视图渲染的原则如下:
60 frames per second is the gold standard, 16.67 milliseconds per frame
剩下的部分代码:
@class DTRulerView;
@protocol DTRulerViewDelegate
- (void)rulerViewDidChange:(DTRulerView *)rulerView weight:(float)weight;
@end
@interface DTRulerView : UIScrollView <UIScrollViewDelegate>
@property (nonatomic, weak) id<DTRulerViewDelegate> rulerViewDelegate;
- (instancetype)initWithFrame:(CGRect)frame weight:(float)weight;
@end
- (float)weightPointedToWithRevise:(BOOL)revise {
float weightFloat = 0.0;
CGFloat middleVisibleX = CGRectGetMidX([self visibleBounds]);
for (int i = 0; i < self.rulerScales.count; i++) {
DTRulerViewScale *rulerScale = [self.rulerScales objectAtIndex:i];
CGFloat rulerScaleMinX = CGRectGetMinX(rulerScale.frame);
CGFloat rulerScaleMaxX = CGRectGetMaxX(rulerScale.frame);
// point to which scale block
if (rulerScaleMinX <= middleVisibleX && middleVisibleX < rulerScaleMaxX) {
NSNumber *weight = [self.weights objectAtIndex:i];
for (int j = 0; j < 10; j++) {
CGFloat rulerSingleScaleMinX = rulerScaleMinX + j * DTRulerScaleGap;
CGFloat rulerSingleScaleMidX = rulerSingleScaleMinX + DTRulerScaleGap/2;
CGFloat rulerSingleScaleMaxX = rulerScaleMinX + (j + 1) * DTRulerScaleGap;
// point to which scale block`s single scale
if (rulerSingleScaleMinX <= middleVisibleX && middleVisibleX < rulerSingleScaleMaxX) {
weightFloat = weight.intValue + (j - 5) * 0.1;
// point to scale without offset
if (revise) {
[self moveAllChildrensWithOffsetX:middleVisibleX - rulerSingleScaleMidX animation:YES];
}
break;
}
}
break;
}
}
return weightFloat;
}
- (BOOL)isEnoughTimeElapsed {
static NSTimeInterval lastTime = 0;
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
BOOL isEnough = NO;
if (lastTime == 0) {
isEnough = YES;
} else {
isEnough = currentTime - lastTime > 0.016; // 16.67 milliseconds per frame
}
lastTime = currentTime;
return isEnough;
}
#pragma mark - Delegate Method
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
if (!decelerate) {
[self.rulerViewDelegate rulerViewDidChange:(DTRulerView *)scrollView weight:[self weightPointedToWithRevise:YES]];
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self.rulerViewDelegate rulerViewDidChange:(DTRulerView *)scrollView weight:[self weightPointedToWithRevise:YES]];
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if ([self isEnoughTimeElapsed]) {
[self.rulerViewDelegate rulerViewDidChange:(DTRulerView *)scrollView weight:[self weightPointedToWithRevise:NO]];
}
}