iOS9多工管理器效果iCarousel高階教程

裡脊串的開發隨筆發表於2015-08-06

iOS9馬上要釋出了 為了我司APP的相容性問題 特意把手上的iOS Mac XCode都升級到了最新的beta版 然後發現iOS9的多工管理器風格大變 變成了下面這種樣子

我忽然想起來之前的文章提到我最愛的UI控制元件iCarousel要實現類似這種效果其實是很簡單的 一時興起就花時間試驗了一下 效果還不錯 所以接下來我就介紹一下iCarousel的高階用法: 如何使用iCarousel的自定義方式來實現iOS9的多工管理器效果

模型

首先來看一下iOS9的多工管理器究竟是什麼樣子

然後我們簡單的來建個模 這個步驟很重要 將會影響我們之後的計算 首先我們把東西擺正

然後按比例用線分割一下

這裡可以看到 如果我們以正中間的卡片(設定序號為0)為參照物的話 最右邊卡片(序號為1)的位移就是中心卡片寬度的4/5 最左邊的卡片(序號為-2)的位移就是中心卡片的寬度的2/5 注意:這兩個值的確定對我們非常重要

大小*的縮放 就按照線性放大**就行了 由於計算很簡單 這裡就不多贅述了

細心的人可能會注意到 其實iOS9中的中心卡片 並不是居中的 而是靠右的 那麼我們再把整體佈局調整一下

這樣就差不多是iOS9的樣子了

原理

接著我們來了解一下iCarousel的基本原理

iCarousel支援如下幾種內建顯示型別(沒用過的同學請務必使用pod try iCarousel來執行一下demo)

  • iCarouselTypeLinear
  • iCarouselTypeRotary
  • iCarouselTypeInvertedRotary
  • iCarouselTypeCylinder
  • iCarouselTypeInvertedCylinder
  • iCarouselTypeWheel
  • iCarouselTypeInvertedWheel
  • iCarouselTypeCoverFlow
  • iCarouselTypeCoverFlow2
  • iCarouselTypeTimeMachine
  • iCarouselTypeInvertedTimeMachine

具體效果圖可以在官方Github主頁上看到 不過這幾種型別雖然好 但是也無法滿足我們現在的需求 沒關係 iCarousel還支援自定義型別

  • iCarouselTypeCustom

這就是我們今天的主角

還是程式碼說話 我們先配置一個簡單的iCarousel示例 並使用iCarouselTypeCustom作為其型別

@interface ViewController ()
<
iCarouselDelegate,
iCarouselDataSource
>

@property (nonatomic, strong) iCarousel *carousel;
@property (nonatomic, assign) CGSize cardSize;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    CGFloat cardWidth = [UIScreen mainScreen].bounds.size.width*5.0f/7.0f;
    self.cardSize = CGSizeMake(cardWidth, cardWidth*16.0f/9.0f);
    self.view.backgroundColor = [UIColor blackColor];

    self.carousel = [[iCarousel alloc] initWithFrame:[UIScreen mainScreen].bounds];
    [self.view addSubview:self.carousel];
    self.carousel.delegate = self;
    self.carousel.dataSource = self;
    self.carousel.type = iCarouselTypeCustom;
    self.carousel.bounceDistance = 0.2f;

}

- (NSInteger)numberOfItemsInCarousel:(iCarousel *)carousel
{
    return 15;
}

- (CGFloat)carouselItemWidth:(iCarousel *)carousel
{
    return self.cardSize.width;
}

- (UIView *)carousel:(iCarousel *)carousel viewForItemAtIndex:(NSInteger)index reusingView:(UIView *)view
{
    UIView *cardView = view;

    if ( !cardView )
    {
        cardView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.cardSize.width, self.cardSize.height)];

        UIImageView *imageView = [[UIImageView alloc] initWithFrame:cardView.bounds];
        [cardView addSubview:imageView];
        imageView.contentMode = UIViewContentModeScaleAspectFill;
        imageView.backgroundColor = [UIColor whiteColor];

        cardView.layer.shadowPath = [UIBezierPath bezierPathWithRoundedRect:imageView.frame cornerRadius:5.0f].CGPath;
        cardView.layer.shadowRadius = 3.0f;
        cardView.layer.shadowColor = [UIColor blackColor].CGColor;
        cardView.layer.shadowOpacity = 0.5f;
        cardView.layer.shadowOffset = CGSizeMake(0, 0);

        CAShapeLayer *layer = [CAShapeLayer layer];
        layer.frame = imageView.bounds;
        layer.path = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:5.0f].CGPath;
        imageView.layer.mask = layer;
    }

    return cardView;
}

當你執行這段程式碼的時候哦 你會發現顯示出來是下面這個樣子的 並且劃也劃不動(掀桌:這是什麼鬼~(/‵Д′)/~ ╧╧)

這是因為我們有個最重要的delegate方法沒有實現

- (CATransform3D)carousel:(iCarousel *)carousel itemTransformForOffset:(CGFloat)offset

這個函式也是整個iCarouselTypeCustom的靈魂所在

接下來我們要簡單的說一下iCarousel的原理

  • iCarousel並不是一個UIScrollView 也並沒有包含任何UIScrollView作為subView
  • iCarousel通過UIPanGestureRecognizer來計算和維護scrollOffset這個變數
  • iCarousel通過scrollOffset來驅動整個動畫過程
  • iCarousel本身並不會改變itemView的位置 而是靠修改itemView的layer.transform來實現位移和形變

可能文字說得不太清楚 我們還是通過程式碼來看一下

- (UIView *)carousel:(iCarousel *)carousel viewForItemAtIndex:(NSInteger)index reusingView:(UIView *)view
{
    UIView *cardView = view;

    if ( !cardView )
    {
        cardView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.cardSize.width, self.cardSize.height)];

        ...
        ...

        //新增一個lbl
        UILabel *lbl = [[UILabel alloc] initWithFrame:cardView.bounds];
        lbl.text = [@(index) stringValue];
        [cardView addSubview:lbl];
        lbl.font = [UIFont boldSystemFontOfSize:200];
        lbl.textAlignment = NSTextAlignmentCenter;
    }

    return cardView;
}

- (CATransform3D)carousel:(iCarousel *)carousel itemTransformForOffset:(CGFloat)offset baseTransform:(CATransform3D)transform
{
    NSLog(@"%f",offset);

    return transform;
}

然後滑動的時候打出的日誌是類似這樣的

2015-07-28 16:53:22.330 DemoTaskTray[1834:485052] -2.999739
2015-07-28 16:53:22.331 DemoTaskTray[1834:485052] 2.000261
2015-07-28 16:53:22.331 DemoTaskTray[1834:485052] -1.999739
2015-07-28 16:53:22.331 DemoTaskTray[1834:485052] 3.000261
2015-07-28 16:53:22.331 DemoTaskTray[1834:485052] -0.999739
2015-07-28 16:53:22.332 DemoTaskTray[1834:485052] 0.000261
2015-07-28 16:53:22.332 DemoTaskTray[1834:485052] 1.000261

2015-07-28 16:53:22.346 DemoTaskTray[1834:485052] -3.000000
2015-07-28 16:53:22.347 DemoTaskTray[1834:485052] 2.000000
2015-07-28 16:53:22.347 DemoTaskTray[1834:485052] -2.000000
2015-07-28 16:53:22.348 DemoTaskTray[1834:485052] 3.000000
2015-07-28 16:53:22.348 DemoTaskTray[1834:485052] -1.000000
2015-07-28 16:53:22.348 DemoTaskTray[1834:485052] 0.000000
2015-07-28 16:53:22.348 DemoTaskTray[1834:485052] 1.000000

2015-07-28 16:53:22.363 DemoTaskTray[1834:485052] -3.000000
2015-07-28 16:53:22.363 DemoTaskTray[1834:485052] 2.000000
2015-07-28 16:53:22.363 DemoTaskTray[1834:485052] -2.000000
2015-07-28 16:53:22.363 DemoTaskTray[1834:485052] 3.000000
2015-07-28 16:53:22.364 DemoTaskTray[1834:485052] -1.000000
2015-07-28 16:53:22.364 DemoTaskTray[1834:485052] 0.000000
2015-07-28 16:53:22.364 DemoTaskTray[1834:485052] 1.000000

可以看到 所有的itemView都是居中並且重疊在一起的 我們滑動的時候並不會改變itemView的位置 但是這個offset是會改變的 而且可以看到 所有的offset的相鄰差值都為1.0

這就是iCarousel的一個重要的設計理念 iCarousel雖然跟UIScrollView一樣都各自會維護自己的scrollOffset 但是UIScrollView在滑動的時候改變的是自己的ViewPort 就是說 UIScrollView上的itemView是真正被放置到了他被設定的位置上 只是UIScrollView通過移動顯示的視窗 造成了滑動的感覺(如果不理解 請看這篇文章)

但是iCarousel並不是這樣 iCarousel會把所有的itemView都居中重疊放置在一起 當scrollOffset變化時 iCarousel會計算每個itemView的offset 並通過- (CATransform3D)carousel:(iCarousel *)carousel itemTransformForOffset:(CGFloat)offset baseTransform:(CATransform3D)transform這個函式來對每個itemView進行形變 通過形變來造成滑動的效果

這個非常大膽和另類的想法著實很奇妙! 可能我解釋得不夠好(盡力了~~) 還是通過程式碼來解釋比較好

我們修改一下函式的實現

- (CATransform3D)carousel:(iCarousel *)carousel itemTransformForOffset:(CGFloat)offset baseTransform:(CATransform3D)transform
{
    NSLog(@"%f",offset);

    return CATransform3DTranslate(transform, offset * self.cardSize.width, 0, 0);
}

效果如下

我們可以看到 已經可以滑動了 而且這個效果 就是類似iCarouselTypeLinear的效果
沒錯 其實iCarousel所有的內建型別也都是通過這種方式來實現的 只是分別根據offset進行了不同的形變 就造成了各種不同的效果
要說明的是 函式僅提供offset作為引數 並沒有提供index來指明對應的是哪一個itemView 這樣的好處是可以讓人只關注於具體的形變計算 而無需計算與currentItemView之間的距離之類的

注意的是offset是元單位(就是說 offset是不包含寬度的 僅僅是用來說明itemView的偏移係數) 下圖簡單說明了一下

當沒有滑動的時候 offset是這樣的

當滑動的時候 offset是這樣的

怎麼樣 知道了原理之後 是不是有種躍躍欲試的感覺? 接下來我們就回到主題上 看看如何一步步實現我們想要的效果

計算

通過剛才原理的介紹 可以知道 接下來的重點就是關於offset的計算

我們首先來確定一下函式的曲線圖 通過觀察iOS9的例項效果我們可以知道 itemView從左向右滑的時候是越來越快的
所以這個曲線大概是這個樣子的

考驗你高中數學知識的時候到了 怎麼找到這種函式?

有種叫直角雙曲線的函式 大概公式是這個樣子

其曲線圖是這樣的

可以看到 位於第二象限的曲線就是我們要的樣子 但是我們還要調整一下才能得到最終的結果

由於offset為0的時候 本身是不形變的 所以可以知道曲線是過原點(0,0)的 那麼我們可以得到函式的一般式

而在文章開頭我們得到了這樣兩組資料

  • 最右邊卡片(序號為1)的位移就是中心卡片寬度的4/5
  • 最左邊的卡片(序號為-2)的位移就是中心卡片的寬度的2/5

那麼代入上面的一般式中 我們可以得到兩個公式

計算可以得到

a=5/4
b=5/8

然後我們就可以得到我們最終想要的公式

看看曲線圖

然後我們修改一下程式程式碼(這段程式碼其實就是本文的關鍵所在)

- (CATransform3D)carousel:(iCarousel *)carousel itemTransformForOffset:(CGFloat)offset baseTransform:(CATransform3D)transform
{
    CGFloat scale = [self scaleByOffset:offset];
    CGFloat translation = [self translationByOffset:offset];

    return CATransform3DScale(CATransform3DTranslate(transform, translation * self.cardSize.width, 0, 0), scale, scale, 1.0f);
}

- (void)carouselDidScroll:(iCarousel *)carousel
{
    for ( UIView *view in carousel.visibleItemViews)
    {
        CGFloat offset = [carousel offsetForItemAtIndex:[carousel indexOfItemView:view]];

        if ( offset < -3.0 )
        {
            view.alpha = 0.0f;
        }
        else if ( offset < -2.0f)
        {
            view.alpha = offset + 3.0f;
        }
        else
        {
            view.alpha = 1.0f;
        }
    }
}

//形變是線性的就ok了
- (CGFloat)scaleByOffset:(CGFloat)offset
{
    return offset*0.04f + 1.0f;
}

//位移通過得到的公式來計算
- (CGFloat)translationByOffset:(CGFloat)offset
{
    CGFloat z = 5.0f/4.0f;
    CGFloat n = 5.0f/8.0f;

    //z/n是臨界值 >=這個值時 我們就把itemView放到比較遠的地方不讓他顯示在螢幕上就可以了
    if ( offset >= z/n )
    {
        return 2.0f;
    }

    return 1/(z-n*offset)-1/z;
}

再看看效果

看上去已經是我們想要的效果了

不過 滑動一下就會發現問題

原來雖然itemView的大小和位移都按照我們的預期變化了 但是層級出現了問題 那麼iCarousel是如何調整itemView的層級的呢? 檢視原始碼我們可以知道

NSComparisonResult compareViewDepth(UIView *view1, UIView *view2, iCarousel *self)
{
    //compare depths
    CATransform3D t1 = view1.superview.layer.transform;
    CATransform3D t2 = view2.superview.layer.transform;
    CGFloat z1 = t1.m13 + t1.m23 + t1.m33 + t1.m43;
    CGFloat z2 = t2.m13 + t2.m23 + t2.m33 + t2.m43;
    CGFloat difference = z1 - z2;

    //if depths are equal, compare distance from current view
    if (difference == 0.0)
    {
        CATransform3D t3 = [self currentItemView].superview.layer.transform;
        if (self.vertical)
        {
            CGFloat y1 = t1.m12 + t1.m22 + t1.m32 + t1.m42;
            CGFloat y2 = t2.m12 + t2.m22 + t2.m32 + t2.m42;
            CGFloat y3 = t3.m12 + t3.m22 + t3.m32 + t3.m42;
            difference = fabs(y2 - y3) - fabs(y1 - y3);
        }
        else
        {
            CGFloat x1 = t1.m11 + t1.m21 + t1.m31 + t1.m41;
            CGFloat x2 = t2.m11 + t2.m21 + t2.m31 + t2.m41;
            CGFloat x3 = t3.m11 + t3.m21 + t3.m31 + t3.m41;
            difference = fabs(x2 - x3) - fabs(x1 - x3);
        }
    }
    return (difference < 0.0)? NSOrderedAscending: NSOrderedDescending;
}

- (void)depthSortViews
{
    for (UIView *view in [[_itemViews allValues] sortedArrayUsingFunction:(NSInteger (*)(id, id, void *))compareViewDepth context:(__bridge void *)self])
    {
        [_contentView bringSubviewToFront:view.superview];
    }
}

主要就是這個compareViewDepth的比較函式起作用 而這個函式中比較的就是CATransform3D的各個屬性值

我們來看一下CATransform3D的各個屬性各代表什麼

struct CATransform3D
{
CGFloat     m11(x縮放),     m12(y切變),     m13(旋轉),     m14();

CGFloat     m21(x切變),     m22(y縮放),     m23(),     m24();

CGFloat     m31(旋轉),      m32( ),        m33(),     m34(透視);

CGFloat     m41(x平移),     m42(y平移),     m43(z平移),     m44();
};

而所有CATransform3D開頭的函式(比如CATransform3DScale CATransform3DTranslate) 改變的也就是這些值而已

回到整體 我們發現這個函式先比較的是t1.m13 + t1.m23 + t1.m33 + t1.m43; 而m13代表的是旋轉 m23和m33暫時並沒有含義 而m43代表的是z平移 那麼我們只要改變m43就可以了 而改變m43最簡單的辦法就是

CATransform3D CATransform3DTranslate (CATransform3D t, CGFloat tx,CGFloat ty, CGFloat tz)

最後一個引數就是用來改變m43的

那麼我們把之前iCarousel的delegate方法稍微改動一下 將當前的offset設定給最後一個引數即可(因為offset就是按順序傳進來的)

return CATransform3DScale(CATransform3DTranslate(transform, translation * self.cardSize.width, 0, offset), scale, scale, 1.0f);

再看看效果

Bang!
我們已經得到了一個簡單的copycat

小結

文中的demo可以在這裡找到

可以看到 使用iCarousel 我們僅用不到100行就實現了一個非常不錯的效果(關鍵程式碼不到50行) 而無需做很多額外的工作(當然大家就不要揪細節了 比如以漸隱代替模糊 最後一張卡片居中等問題 畢竟這不是個輪子 只是教大家一種方法)

如果大家真正讀懂了這篇文章(可能我寫得不是很清楚 建議看demo 同時讀iCarousel的原始碼來理解) 那麼只要遇到類似卡片滑動的元件 都可以輕鬆應對了

說到這裡 我個人是非常不喜歡重複造輪子的 能用最少的程式碼達到所需的要求是我一直以來的準則 而且很多經典的輪子庫(比如iCarousel)也值得你去深入探索和學習 瞭解作者的想法和思路(站在巨人的肩膀)是一種非常不錯的學習方法和開闊視野的途徑

另外 文中所用到的數學公式曲線圖生成網站是Desmos Graphing Calculator(從@KITTEN-YANG那瞄到的) 數學公式生成網站是Sciweaver(直接把前者的公式複製到後者的輸入框裡就可以了 因為前者複製出來就是latex格式的公式了) 有需要的同學可以研究一下如何使用 (打算研究一下Matlab的用法 可能更方便)

相關文章