先日、iOS5でも動作するUIRefreshControlと銘打ったISRefreshControlというライブラリを公開しました。 今回はISRefreshControlでやっていることについて、簡単に解説したいと思います。

詳細には踏み入らずにアイディアとコアのコードだけを書きますので、 アプリに組み込む場合にはGitHubに上がっているものを利用することをおすすめします。

ISRefreshControl

基本方針

  • iOS6: 本物のUIRefreshControlとして動作する。
  • iOS5: UIRefreshControlの真似をする。

使い方

UIRefreshControlと概ね同じ使い方ができます。

UIScrollView *scrollView = [[UIScrollView alloc] init];
ISRefreshControl *refreshControl = [[ISRefreshControl alloc] init];
[scrollView addSubview:refreshControl];
[refreshControl addTarget:self
                   action:@selector(refresh)
         forControlEvents:UIControlEventValueChanged];

または

self.refreshControl = (id)[[ISRefreshControl alloc] init];
[self.refreshControl addTarget:self
                        action:@selector(refresh)
              forControlEvents:UIControlEventValueChanged];

実現するためにやったこと

  • iOS5のときだけUITableViewControllerrefreshControlプロパティを生やす。
  • superviewとなるUIScrollViewcontentOffsetをキー値監視して、閾値を超えたらUIControlEventValueChangedを送る。
  • contentOffsetに応じてびよーんってなるやつを頑張って描画する。

iOS6ではUIRefreshControlとして動作させる

+ (id)alloc内でUIRefreshControlクラスの存在を判定し、存在すればUIRefreshControlのインスタンスを、 存在しなければISRefreshControlのインスタンスを返すようにしました。

ISRefreshControl



+ (id)alloc
{
    if ([UIRefreshControl class]) {
        return (id)[UIRefreshControl alloc];
    }
    return [super alloc];
}

contentOffsetをキー値監視する

UIRefreshControlUIScrollViewに追加されるとスクロール量に応じてUIControlEventValueChangedを発火させます。 これを再現するにはsuperviewcontentOffsetをキー値監視する必要があります。 適切にキー値監視を開始/終了させるため、superviewに追加されたときにキー値監視を開始し、 superviewから削除された時にキー値監視を終了させます。

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    if ([self.superview isKindOfClass:[UIScrollView class]]) {
        [self.superview removeObserver:self forKeyPath:@"contentOffset"];
    }
}

- (void)didMoveToSuperview
{
    if ([self.superview isKindOfClass:[UIScrollView class]]) {
        [self.superview addObserver:self forKeyPath:@"contentOffset" options:0 context:NULL];
        
        self.frame = CGRectMake(0, -50, self.superview.frame.size.width, 50);
        self.autoresizingMask = UIViewAutoresizingFlexibleWidth;
        [self setNeedsLayout];
    }
}

UIRefreshControlを上部に配置させるため、didMoveToSuperviewframeを適当に設定しています。

びよーんってなるやつを描画する

ISRefreshControlはびよーんってなるViewを別のクラス(ISGumView)で実装しています。 contentOffsetの変更毎にこのViewのsetNeedsLayoutを呼び、 drawRectでスクロール量に応じたものをCGPathで描画します。

mainRadiusは”びよーん”の上部の丸の半径、subRadiusは下部の丸の半径を表しています。

- (void)drawRect:(CGRect)rect
{
    ...

    CGContextRef ctx = UIGraphicsGetCurrentContext();
    CGMutablePathRef path = CGPathCreateMutable();
    
    CGPathMoveToPoint(path, NULL, offset, 25);
    CGPathAddArcToPoint(path, NULL,
                        offset, 0,
                        offset + self.mainRadius, 0,
                        self.mainRadius);
    
    CGPathAddArcToPoint(path, NULL,
                        offset + self.mainRadius*2.f, 0,
                        offset + self.mainRadius*2.f, self.mainRadius,
                        self.mainRadius);

    CGPathAddCurveToPoint(path, NULL,
                          offset + self.mainRadius*2.f,            self.mainRadius*2.f,
                          offset + self.mainRadius+self.subRadius, self.mainRadius*2.f,
                          offset + self.mainRadius+self.subRadius, self.distance+self.mainRadius);
    
    CGPathAddArcToPoint(path, NULL,
                        offset + self.mainRadius+self.subRadius, self.distance+self.mainRadius+self.subRadius,
                        offset + self.mainRadius,                self.distance+self.mainRadius+self.subRadius,
                        self.subRadius);
    
    CGPathAddArcToPoint(path, NULL,
                        offset + self.mainRadius-self.subRadius, self.distance+self.mainRadius+self.subRadius,
                        offset + self.mainRadius-self.subRadius, self.distance+self.mainRadius,
                        self.subRadius);
    
    CGPathAddCurveToPoint(path, NULL,
                          offset + self.mainRadius-self.subRadius, self.mainRadius*2.f,
                          offset + 0, self.mainRadius*2.f,
                          offset + 0, self.mainRadius);
    
    CGPathCloseSubpath(path);
    CGContextAddPath(ctx, path);
    CGContextSetFillColorWithColor(ctx, [UIColor lightGrayColor].CGColor);
    CGContextFillPath(ctx);
    CGPathRelease(path);
}

UITableViewControllerを拡張する

iOS6のUITableViewControllerにはrefreshControlプロパティが用意されていて、 addSubview:を呼ぶことなくUIRefreshControlを設定することが可能となっています。

@property (nonatomic,retain) UIRefreshControl *refreshControl NS_AVAILABLE_IOS(6_0);

当然、iOS5にこのプロパティは存在しないので、拡張する必要があります。 カテゴリで普通に拡張するとiOS6で衝突してしまうので、iOS5のときだけ+ (void)loadで動的にアクセサを追加します。 既存のクラスにインスタンス変数を追加することはできないので、Associated Objectを使って同等のものを実現しています。

@implementation UITableViewController (RefreshControl)

+ (void)load
{
    @autoreleasepool {
        if ([[[UIDevice currentDevice] systemVersion] hasPrefix:@"5"]) {
            Swizzle([self class], @selector(refreshControl),     @selector(iOS5_refreshControl));
            Swizzle([self class], @selector(setRefreshControl:), @selector(iOS5_setRefreshControl:));
            Swizzle([self class], @selector(viewDidLoad),        @selector(iOS5_viewDidLoad));
        }
    }
}

#pragma mark -

- (void)iOS5_viewDidLoad
{
    [super viewDidLoad];
    
    if (self.refreshControl) {
        [self.view addSubview:self.refreshControl];
    }
}

- (ISRefreshControl *)iOS5_refreshControl
{
    return objc_getAssociatedObject(self, @"iOS5RefreshControl");
}

- (void)iOS5_setRefreshControl:(ISRefreshControl *)refreshControl
{
    if (self.isViewLoaded) {
        ISRefreshControl *oldRefreshControl = objc_getAssociatedObject(self, @"iOS5RefreshControl");
        [oldRefreshControl removeFromSuperview];
        [self.view addSubview:refreshControl];
    }
    
    objc_setAssociatedObject(self, @"iOS5RefreshControl", refreshControl, OBJC_ASSOCIATION_RETAIN);
}

@end

ここで使用されているSwizzleという関数はいわゆるMethod Swizzlingを行うもので、 以下のように実装されています。

void Swizzle(Class c, SEL original, SEL alternative)
{
    Method orgMethod = class_getInstanceMethod(c, original);
    Method altMethod = class_getInstanceMethod(c, alternative);
    
    if(class_addMethod(c, original, method_getImplementation(altMethod), method_getTypeEncoding(altMethod))) {
        class_replaceMethod(c, alternative, method_getImplementation(orgMethod), method_getTypeEncoding(orgMethod));
    } else {
        method_exchangeImplementations(orgMethod, altMethod);
    }
}

viewDidLoadにも手を加えているのは、+ (id)init+ (id)initWithCoder:で 既にrefreshControlプロパティが設定されているケースを考慮するためです。

まとめ

ISRefreshControlは以上のアイディアに沿って実装しました。 同じようなアイディアでiPhoneに対応したUIPopOverControllerも作れたりするのではないかと思います。

ISRefreshControlにはアニメーションの詰めが甘い箇所があったりするので、開発に協力してくださる方は是非GitHubでpull requestをください。