如何輕松實現iOS9多任務管理器效果(iCarousel高級教程)

分類:技術 時間:2017-01-13

前言

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 ()
lt;
iCarouselDelegate,
iCarouselDataSource
gt;

@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(@quot;%fquot;,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(@quot;%fquot;,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 lt; -3.0 )
        {
            view.alpha = 0.0f;
        }
        else if ( offset lt; -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是臨界值 gt;=這個值時 我們就把itemView放到比較遠的地方不讓他顯示在屏幕上就可以了
    if ( offset gt;= 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 lt; 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的用法 可能更方便)


Tags: iOS開發

文章來源:http://www.jianshu.com/p/3fe9395b99c9


ads
ads

相關文章
ads

相關文章

ad