你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 【投稿】如何實現炫酷的卡片式動畫!

【投稿】如何實現炫酷的卡片式動畫!

編輯:IOS開發基礎

今天要實現這個動畫,來自 Dribbble。

15.gif

實際效果:

37334-560f97505b4a9936.gif

Card Animation.gif

源代碼:https://github.com/seedante/CardAnimation.git

關鍵詞:transform, anchor point, frame-based layout, auto layout

看點總結:實踐了基本的 transform 動畫,趟過代碼中建立視圖與 AutoLayout 配合的坑,解決了旋轉時動畫視圖背景透明以及 AutoLayout 中調整 anchorPoint 的難題。

動畫分析

首先是翻轉動作。看下圖的旋轉示意圖,使用 UIView 的 transform 屬性是無法完成上圖的動作的,因為它只支持 Z 軸的旋轉;這裡必須使用 CALayer 的 transfrom 屬性,後者支持三個緯度的旋轉。

37334-e30d5b2d77274979.png

這裡是沿著 X 軸旋轉,使用CATransform3DRotate ( baseTransform, angle, 1, 0, 0)。transform 的每次賦值都是針對原始狀態的調整,而不是前一個 transfrom 的調整。而生成 transform 值則是對傳入的baseTransform 的累積變化,因此在代碼裡調整到需要的效果經常會看到不斷對某個 transform 值迭代。

var flipTransform3D = CATransform3DIdentity//從原始狀態開始
flipTransform3D.m34 = -1.0 / 1000.0//設定視覺焦點,分母越大表示視圖離我們的距離越遠,數值大有什麼好處呢,你會發現翻轉效果就不會產生你討厭的側邊幅度過大的問題。
flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI), 1, 0, 0)//沿 X 軸旋轉180度
//在上面效果的基礎上再向右方和下方分別移動100單位 
var thenMoveTransform3D = CATransform3DTranslate(flipTransform3D, 100, 100, 0)

然後這裡的旋轉不是沿著默認的中心點旋轉的,而是視圖的底部,這意味著我們需要調整 anchor point 為(0.5, 1),然而我們都知道調整 anchor point 後會導致視圖的 position 移動,關於 position 和 anchor point 的關系,推薦一篇我見過的說得最清楚的博客。關於這個翻轉動作,還有一個小問題,那就是無論你使用哪種方式實現旋轉,旋轉過程中我們總是會看到視圖原來的內容,就像旋轉一個印著內容的透明玻璃,這不是我們想要的,一點也不符合顯現實中翻轉一張卡片的效果。要怎麼解決,方法也很簡單,繼續往下看。

關於卡片的擺放,很多人應該都知道「近大遠小」的透視原理,我最早知道這個是在鳥山明的漫畫小劇場裡看到這種作畫技巧來實現在二維平面上實現不同距離的景物的縱深感覺。但注意觀察,上面的動畫裡每張卡片在 X 軸和 Y 軸方向的差距並不是想等的,這個細節很贊。而且要注意,照片的白色邊框的寬度也是不一樣的,這也和前面這個細節匹配。所以,第二步,我們要設定卡片之間的垂直間距以及水平間距,還有卡片的邊框寬度,可以設置一個線性函數來計算這些參數,合適的數值需要通過調試直到達到你的要求為止。第一個動作完成後,後續的卡片依次前進到前面卡片的位置,對後面的卡片依次進行動畫就可以,這裡主要是 frame(size 和position)以及 borderWidth 的變化。

這個動畫裡還有不少細節,動畫的背景是很深的顏色,而後面的卡片比較暗,非常有真實感。在實現的時候可以減小視圖的 alpha 屬性,但是 alpha 是透明度,後面的卡片會被透視,相比這裡還是很有瑕疵的。我在實現時也試圖用漸變色來體現這種感覺,但還是不夠好。一個猜想,使用 CALayer 的 mask 屬性或許可以達到比較好的效果,但沒空試試了。

動畫的細節非常重要。

Frame-based Layout Animation

PS: 如果只想了解怎麼使用 AutoLayout 來實現效果,恩,還是希望你看看此小節,因為大部分內容是一樣的,我也不想再重復內容。基於 frame 實現效果時有些問題沒有解決,直到使用 AutoLayout 後才知道問題所在,但是沒動力去解決了,等我哪天有心情了再說吧(真不負責啊)。相比使用 AutoLayout 實現的版本,這個版本完成了圖中的效果,但沒有實現卡片的復用,自適應布局以及代碼優化(主要是 AutoLayout 版本改了更好的名字^_^),但沒有動力完成了。如果你也還沒有使用過 AutoLayout,可以看看此節來過渡一下。

在實現這個動畫的前期,我並沒有多少使用 AutoLayout 的經驗,盡管我開啟了 AutoLayout,不過基本上是靠本能來使用(就是沒有去學習過 AutoLayout,基本是按照以前的 spring-strut 的經驗來使用的)。或者說,實際上我是將 AutoLayout 與 Spring-Strut 模式混合使用的。這也造成了我在實現一些效果時出現的問題無法解決,不過基本完成了效果圖中的效果,在重新使用 AutoLayout 來實現這個動畫搞懂其中一些問題後對使用 frame-based layout 來實現效果簡直有種歐陽峰倒著修煉九陰真經的感覺。

那麼先說說基於 frame 來實現動畫的過程,實際上和使用 AutoLayout 來實現時大部分關鍵代碼都是通用的,只不過在調整 anchor point 和 frame 時實現方式不同。

動畫准備

在 storyboard 裡這裡安放視圖,在這裡使用了內嵌 UIImageView 的 UIView,本來直接使用 UIImageView 也可以,但前面的組合能夠破解旋轉時的透明背景問題。這裡將第一張卡片調整為屏幕居中,400 的寬度,4:3 的長寬比。為了省事,前期我在 storyboard 裡直接放了8個同樣的視圖,只是內容不一樣,也沒有實現重用。獲取卡片視圖可以使用UIView.viewWithTag(),按照我的習慣,tag 從1開始計數。對於這種動畫,使用手勢來操作無疑是最佳選擇,而為了使用動畫可以交互,使用 pan 手勢。不過我也提供了使用 Button 來執行動作。

37334-efb63979e6cf1a03.png

視圖初始時需要調整 storyboard 裡的視圖以符合卡片的擺放效果。另外,還需要針對翻轉動作做一些准備,必須在翻轉前保證要翻轉的卡片的 anchor point 移動到了卡片的底部,也就是(0.5, 1)。

該怎麼調整 frame 以及 anchor point 呢?我最早的實現裡,所有卡片都是相同的 frame,采用 transformScale 的方法來調整大小,而 transformScale 是以 anchor point 為中心點縮放的,這種方式還會將 Y 軸上的距離也縮放了,這是我不想要的;旋轉卡片需要調整 anchor point 的位置,而為了保持視圖的位置不移動,又需要調整 frame 或 point 了。這兩者的執行順序不一樣又會造成不同的效果,這在使用 pan 手勢執行可交互的翻轉動作時又會出現問題,總之是吃力不討好。後來我還異想天開地在 pan 手勢裡調整 anchor point,但調整 anchor point 的同時為了保證視圖的位置不漂移還得調整 position 或 frame。而調整 frame,需要注意 runloop,而此時 transfrom 在手勢裡不斷變化,這幾種加在一起,不會產生你想要的效果。

現在的實現則是初始階段將所有卡片視圖的 frame 調整至需要的值,並調整好 anchor point,最好一切准備,一勞永逸解決各種小毛病。這次的實現,為了省事,沒有維護視圖的列表,而是直接通過 viewTag 來獲取對應視圖,這也給復用帶來了一點點小問題。

變量設定:

var frontCardTag = 1 //最前面的卡片的 viewTag
var cardCount = 8 //我剛開始只是隨便設置了這麼多,視覺上效果比較好
var originFrame = CGRectZero //保存最前面卡片的 frame,主要是為了應對屏幕方向的變化,便於計算後續卡片的 frame。
var gestureDirection:panScrollDirection = .Up //記錄 pan 手勢的起始方向

frame 以及 anchorPoint 調整:
21.png
除了這些,還需要一些輔助函數,根據卡片在屏幕上的相對位置來計算與前一張卡片在 Y 軸上的間距,在 X 軸方向尺寸的縮小比例,以及 alpha 的設定。前期,我將 borderWidth 的值設定為 5*scale,但後期發現使用1/100的 width 值在視覺上更舒服。再次說明一次,卡片間的垂直距離是依次遞減的,卡片的尺寸和邊框寬度的縮放比例也是依次遞減的,為了不那麼復雜,都是線性遞減的,具體實現可以看代碼。這些不是此次的重點,隨你自己喜好設定就行。

37334-989f6cb126a08966.jpg

參數設定-啊,我的字真丑

動畫實現

做完了這些准備,先來實現簡單一點的操作:點擊按鈕後執行翻轉操作以及移動後面的卡片到前面卡片的位置。

向下翻動卡片:

@IBAction func flipDown(sender: AnyObject) {
    //邊界判定
    if frontCardTag > cardCount{
        return
    }
    guard let frontView = view.viewWithTag(frontCardTag) else{
        return
    }
    
    var flipDownTransform3D = CATransform3DIdentity
    //m34這個值用來表示視覺上焦點的位置,不明白的話,只需要知道設置的值越大相當於卡片離你的距離越遠,
    //而此時看到的翻轉效果就不會產生你討厭的側邊幅度過大的問題。
    flipDownTransform3D.m34 = -1.0 / 1000.0  
    //此處有個很大的問題,折磨了我幾個小時。原來官方的實現有個臨界問題,旋轉180度不會執行,直接跳轉,其他的角度則沒有問題。
    //而在手勢裡卻沒有問題,可能在手勢裡和 button action 的運行機制不一樣。
    flipDownTransform3D = CATransform3DRotate(flipDownTransform3D, CGFloat(-M_PI) * 0.99, 1, 0, 0)
    UIView.animateWithDuration(0.3, animations: {
        frontView.layer.transform = flipDownTransform3D
        }, completion: {
            _ in
            frontView.hidden = true
            self.adjustDownViewLayout()
    })
}
//將後面的卡片依次移動到前面並設定新的 frame,borderWidth(其實就是使用前面卡片的設定)
func adjustDownViewLayout(){
    frontCardTag += 1
    if frontCardTag <= cardCount{
        for viewTag in frontCardTag...cardCount{
            if let subView = view.viewWithTag(viewTag){
                //delay 時間的間隔可以實現不同的視覺效果,同步移動還是異步移動,看你的需要了
                let delay: NSTimeInterval = 0.1 * Double(viewTag - frontCardTag)
                UIView.animateWithDuration(0.3, delay: delay, options: UIViewAnimationOptions.CurveEaseIn, animations: {
                    let (frame, borderWidth) = self.calculateFrameAndBorderWidth(viewTag - self.frontCardTag, initialBorderWidth: 5)
                    subView.frame = frame
                    subView.layer.borderWidth = borderWidth
                    }, completion: nil)
            }
        }
    }
}

向上恢復卡片:

@IBAction func flipUp(sender: AnyObject) {
    if frontCardTag == 1{
        return
    }
    guard let previousFrontView = view.viewWithTag(frontCardTag - 1) else{
        return
    }
    var flipUpTransform3D = CATransform3DIdentity
    flipUpTransform3D.m34 = -1.0 / 1000.0
    flipUpTransform3D = CATransform3DRotate(flipUpTransform3D, 0, 1, 0, 0)
    UIView.animateWithDuration(0.3, animations: {
        previousFrontView.hidden = false
        previousFrontView.layer.transform = flipUpTransform3D
        }, completion: {
            _ in
            self.adjustUpViewLayout()
    })
}

func adjustUpViewLayout(){
    if frontCardTag >= 2{
        //代碼裡我弄了兩種效果,一個從前往後,一個從後往前
        for var viewTag = frontCardTag; viewTag <= cardCount; ++viewTag{
            if let subView = view.viewWithTag(viewTag){
                let relativeIndex = viewTag - self.frontCardTag + 1
                let delay: NSTimeInterval = Double(viewTag - frontCardTag) * 0.1
                UIView.animateWithDuration(0.2, delay: delay, options: UIViewAnimationOptions.BeginFromCurrentState, animations: {
                let (frame, borderWidth) = self.calculateFrameAndBorderWidth(relativeIndex, initialBorderWidth: 5)
                   subView.frame = frame
                   subView.layer.borderWidth = borderWidth
                }, completion: nil)
            }
        }
        frontCardTag -= 1
    }
}

交互動畫

在 pan 手勢執行的代碼裡,很多參數並不是我開始就知道的,需要不斷調試來判斷如何使得角度與進度配合得到預期的效果。

在 pan 手勢裡,根據手勢在屏幕上移動的距離來判斷進度:

let percent = gesture.translationInView(view).y/150 //y 值可以為負,因此進度也會是負值
在手勢的開始階段根據速度的正負來判斷執行的操作。
case .Began:
if velocity.y > 0{
    //向下翻轉卡片
    gestureDirection = .Down
}else{
    //將下方的卡片翻回上面
    gestureDirection = .Up
}

在手勢的變化階段,需要將動畫過程交互化,調整翻轉的角度與進度匹配,需要注意邊界條件的判定。
29.png
翻轉卡片時,當卡片與屏幕垂直,繼續翻轉時,此時卡片背面應該無法看見卡片正面的內容,然而 iOS 提供的所有翻轉方式裡視圖層都是透明的,剛開始我想在此時添加背景視圖來覆蓋卡片的內容,然而此時出現了卡片的位置偏移的問題,百思不得其解。一計不成,再想一計。在 storyboard 裡設置卡片背景顏色為需要的顏色,當卡片與屏幕垂直繼續翻轉時將圖片視圖隱藏,Bingo,同時,完善細節,將 borderWidth 修改為0。而前面的方案 bug 的關鍵在於代碼生成 UIView 實例時,translatesAutoresizingMaskIntoConstraints屬性默認為true,而這將視圖的 resize mask 與 AutoLayout 混合,造成了這個 bug。而這個問題在我學習 AutoLayout 時才搞清楚。不過即使搞清楚也沒辦法解決這個問題,因為兩者的混合總是會帶來一些意想不到的問題,還是使用第二種方案比較好。

case .Change:
/...
do other thing
../
if percent >= 0.5{
    if let subView = frontView?.viewWithTag(10){
        subView.hidden = true
        frontView?.layer.borderWidth = 0
    }
}else{
    if let subView = frontView?.viewWithTag(10){
        subView.hidden = false
        frontView?.layer.borderWidth = 5
    }
}

pan 手勢方法的完整實現:

func scrollOnView(gesture: UIPanGestureRecognizer){
    //臨界條件的判斷
    if frontCardTag > cardCount + 1{
        frontCardTag -= 1
        return
    }
    if frontCardTag < 1{
        frontCardTag += 1
        return
    }
    let frontView = view.viewWithTag(frontCardTag)
    let previousFrontView = view.viewWithTag(frontCardTag - 1)
    let velocity = gesture.velocityInView(view)
    let percent = gesture.translationInView(view).y/150
    var flipTransform3D = CATransform3DIdentity
    flipTransform3D.m34 = -1.0 / 1000.0
    switch gesture.state{
    //手勢的開始階段判斷向上翻動還是向下翻動卡片
    case .Began:
        if velocity.y > 0{
            gestureDirection = .Down
        }else{
            gestureDirection = .Up
        }
    case .Changed:
        if gestureDirection == .Down{
            switch percent{
            case 0.0..= 0.5{
                    if let subView = frontView?.viewWithTag(10){
                        subView.hidden = true
                        frontView?.layer.borderWidth = 0
                    }
                }else{
                    if let subView = frontView?.viewWithTag(10){
                        subView.hidden = false
                        frontView?.layer.borderWidth = 5
                    }
                }
            case 1.0...CGFloat(MAXFLOAT):
                flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI), 1, 0, 0)
                frontView?.layer.transform = flipTransform3D
            default:
                print(percent)
            }
        } else {
            if frontCardTag == 1{
                return
            }
            previousFrontView?.hidden = false
            switch percent{
            case CGFloat(-MAXFLOAT)...(-1.0):
                previousFrontView?.layer.transform = CATransform3DIdentity
            case -1.0...0:
                if percent = 0.5{
                flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(M_PI), 1, 0, 0)
                UIView.animateWithDuration(0.3, animations: {
                    frontView?.layer.transform = flipTransform3D
                    }, completion: {
                        _ in
                        frontView?.hidden = true
                        if frontView != nil{
                            self.adjustDownViewLayout()
                        }
                })
            }else{
                //不然就原路返回,取消翻轉
                UIView.animateWithDuration(0.2, animations: {
                    frontView?.layer.transform = CATransform3DIdentity
                })
            }
        case .Up:
            if frontCardTag == 1{
                return
            }
            if percent <= -0.5{
                UIView.animateWithDuration(0.2, animations: {
                    previousFrontView?.layer.transform = CATransform3DIdentity
                    }, completion: {
                        _ in
                        self.adjustUpViewLayout()
                })
            }else{
                UIView.animateWithDuration(0.2, animations: {
                    previousFrontView?.layer.transform = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI), 1, 0, 0)
                    }, completion: {
                        _ in
                        previousFrontView?.hidden = true
                })
            }
        }
    default:
        print("DEFAULT: DO NOTHING")
    }
}

Frame-Based Layout 的隱患

到這裡為止,遇到的大部分問題都被解決了。直到我試圖在代碼裡添加新的卡片,但總是會導致其他卡片發生漂移。這時候我還沒有實現復用機制,而添加新卡片總是需要的,不能避開這個問題,但用盡了我知道的方法依然無法解決,這時候我意識到可能需要換個方向了。

事實上這個問題也很簡單,在代碼裡添加 UIView 時,切記將translatesAutoresizingMaskIntoConstraints屬性值修改為false。這個屬性用來決定是否將 frame 驅動的 Spring-Strut 布局模式與 AutoLayout 模式混合,值為 true 時,則將傳統的 frame 與 AutoLayout 結合,你可以直接修改 frame,AutoLayout 的約束機制會自動將這個變化轉變為約束。在 storyboard 裡生成的 UIView 的這個屬性默認為 false,在代碼裡生成的 UIView 的這個屬性默認為 true。聽起來非常美好吧,這是來自今年的 WWDC 的高級技巧:Mysteries of Auto Layout, Part 2,在視頻下方的搜索裡輸入該屬性,會列出視頻裡提到該詞的地方,這是今年蘋果為開發者出的一個非常有用的功能。這個視頻首先是從 How I Learned to Stop Worrying and Love Cocoa Auto Layout 這篇文章裡知曉的,這篇文章列舉了 AutoLayout 的一些非常重要的優點和缺點,非常值得一讀。

然而我的實踐卻正好相反:在上面的實現裡,我在不知曉 AutoLayout 的情況下,直接更改 frame 來執行動畫,而此時該屬性值為 false,不應該如此,然而 frame 和約束就這樣和諧地配合了;然而從代碼添加卡片時導致了其他卡片的位置移動,此時這些屬性值為 true,那麼這個行為也不該如此呀。而我嘗試手動將 storyboard 裡獲取的視圖的該屬性值設定為 true 時,又出現了一系列的約束沖突。顯然,蘋果的工程師不會在 WWDC 裡犯這種錯誤,那麼肯定是我修行不夠,哪兒有點纰漏。等日後我搞明白了再更新。

總之,我稀裡糊塗地將兩種模式混合使用,然後在一個不起眼的小地方翻了船,不得不尋求他法。換一種全新的方式來實現,還得重新學,聽說還很復雜? How I Learned to Stop Worrying and Love Cocoa Auto Layout 這篇文章裡列舉的使用 AutoLayout 可能會遇到的麻煩這個動畫恰好就占了最重要的那一條:anchor point,transform 與 Autolayout 不和。

37334-ccc11a96cc7757a8.jpg

好好休息一下,泡杯咖啡,提升一下決心,再來學習 AutoLayout 吧。官方文檔以及 raywenderlich.com 關於 AutoLayout 的兩篇文章 Part I和 Part II都是極好的入門指南,後者還指出了傳統的 Spring-Strut 布局方案的局限,建議先讀後者再看前者,前者比較全但不能滿足你急切解決問題的心情,後者也不能解決今天的這個問題,但是能讓你快速了解 AutoLayout 便於進入狀態。

AutoLayout Animation

其實在上面的小節裡已經將所有的問題解決了,但是那是在學習了 AutoLayout 後才知道的馬後炮解決手段。AutoLayout 應該很好學,畢竟我之前都是靠著本能來使用的,只不過不太了解與 AutoLayout 直接打交道的具體手法,不過手動調整約束的代碼比起修改 frame ,兩者對程序員的吸引程度似乎不是一個緯度,大大打消學習熱情。

AutoLayout 科普入門(非小白可跳過)

首先,總結一下,傳統的 Spring-Strut 布局方案與 AutoLayout 布局方案的差異,為什麼蘋果拋棄了前者?raywenderlich.com 家的文章說得很清楚,Spring-Strut 描述了 superView 與 subView 之間的布局關系,但缺乏對平行的 subView 間的布局描述,AutoLayout 補上了這個缺。那為何不直接將前者改造成後者或者把框架名字換一下呢?底層實現不清楚,不瞎猜了。

其次,使用 AutoLayout 要拋棄之前 frame 的概念,改而使用約束 constraint。在 AutoLayout 的世界裡,視圖的位置和大小都由附在視圖上的約束來決定,這個過程很像我們針對視圖的尺寸和位置等數據設置了一堆方程式來交給 AutoLayout 來運算。如果這堆方程式是可解的,那麼視圖的布局就是確定的;如果方程無解,就會發生沖突,你將在控制台看見一大堆的報告;如果方程式條件不足,AutoLayout 無法給出唯一解,就沒法確定視圖的布局。

37334-86812261672a8f80.png

而 AutoLayout 裡決定布局不止 constraint,看上圖,還有約束的 priority,以及視圖本身的固有尺寸 intrinsicContentSize,其實看名字就很好理解了。在這個動畫這裡,可以只考慮約束就可以完成動畫了,這也是從 frame-based layout 轉變到 AutoLayout 最無痛的方式了。

比如,某個視圖的 frame 為(100, 300, 400, 300), 向移動100個單位:

let oldFrame = subView.frame
let newFrame = CGRectMake(oldFrame.origin.x + 100, oldFrame.origin.y, oldFrame.size.width, oldFrame.size.height)
UIView.animateWithDuration(0.3, {
    subView.frame = newFrame
})

那麼使用 AutoLayout 怎麼實現這個動畫?首先我們要改用約束來描述該視圖的布局。約束條件非常靈活,可以有多種方案,最簡單的一種,這裡對於視圖在 X 方向的位置約束可以描述為視圖的左側 leading 距離父視圖的leading 距離為100單位,現在要將這個距離修改為200單位。配合稍微有點不搭,湊合看看。

37334-33e04cf480ca1a91.png

官方對 constraint 的圖解

約束使用NSLayoutConstraint類,剛開始看著頭疼,多寫寫就習慣了。不過,這裡有個地方要注意,約束描述了視圖和其他視圖的關系,一般都是雙向的,UIView 的 constraints 裡保存了視圖的約束,那怎麼找到我們需要的約束呢,雙向關系的約束保存在哪裡,雙方都有一份嗎?記住,視圖只保存自己與自身子視圖之間的約束以及自身子視圖之間的約束。那麼上面視圖的約束就保存在父視圖的約束裡,找出來修改:

for constraint in superView.constraints{
    if constraint.firstItem == subView && constraint.secondItem == superView && constraint.firstAttribute == .CenterX{
        constraint.constant = 200
        break
    }
}
//或者使用 filter 功能
let centerXConstraint = superView.constraints.filter({$0.firstItem as? UIView == subView && $0.secondItem as? UIView == superView && $0.firstAttribute == .CenterX})[0]
centerXConstraint.constant = 200
//修改約束後,要求父視圖重新布局。雖然上面的修改本身是即時的,但需要這樣才能用動畫表現
UIView.animateWithDuration(0.3, {
    superView.layoutIfNeeded()
})

這樣看起來,似乎要比 frame 動畫麻煩好多啊,的確是這樣。不過,對於卡片動畫中調整各卡片距離時,AutoLayout 實現可以簡單得多:其他卡片添加對前面一張卡片的距離約束,修改第一張卡片的位置約束,就能自動調整其他卡片的位置,如果用 frame 來實現,得去修改每一張卡片的 frame。不過在這次的 AutoLayout 實現裡,我沒有選擇這麼做,還是選用 frame 的策略,修改每一張卡片相對父視圖 centerY的約束。為何?因為,前面的卡片可能會被移除出視圖,這樣約束也會隨之消失,或者前面的卡片會被重用而修改約束,此時兩者之間的約束關系就需要發生變化。那麼,全部針對父視圖的 centerY 添加約束,雖然麻煩需要逐個修改,但這個約束條件就穩定多了。

這裡有個例子,修改約束的 Priority 來執行動畫,AutoLayout 的確是很靈活,也大大增加了復雜性,我到現在還是很難擯棄原來的 frame 的思維方式,大部分時候還是將 frame 動畫重新用約束來寫罷了。那麼基本的 AutoLayout 動畫會了,接下來,解決最大的難點:anchor point.

AutoLayout And AnchorPoint

視圖的 anchor point 是視圖進行縮放,移動,旋轉的中心點,實際上它是視圖的 position 在自身坐標系的投影,對於兩者的關系,依然推薦這篇博客。那麼在 AutoLayout 中,怎麼調整 anchor point 呢?statckoverflow 上兩年前就討論這問題了,下面的回答裡有第一個高票回答非常精彩,還順帶回答了 transform 與 AutoLayout 的問題,這又是下一個難點,不過似乎有點跑題了,沒有直接回答調整 anchor point 的問題。也許是因為問題是兩年前的,AutoLayout 也進化了兩年了,可能當初的問題現在被解決了。根據回答,iOS 7 裡 transform 與 AutoLayout 不怎麼和睦,兩者的結合通常不會有好結果,直到 iOS 8 才和諧起來,著名的界面調試軟件 reveal 的博客裡就有這麼一篇文章 Constraints & Transformations 講述了 iOS 8 裡兩者是怎麼愉快相處的。我也扯遠了。那個高票回答裡提出一種解決方案,將要調整 anchor point 和要旋轉的視圖內嵌在容器視圖裡,在容器視圖內調整 anchor point 和旋轉,一舉兩得。然而,我還沒來得及實驗這種方法就已經找到另外一種方法。

不過首先,得先轉換到 AutoLayout 的環境下,這時候不能像 frame-based layout 那樣設定約束了。實際上大部分還是相同的,只不過在使用時會修改一些我以前不知道的地方罷了。所有視圖依然居中,還記得上一節那個配圖中的約束公式嗎,那個常量值為0,以前我直接修改 frame,現在修改這個常量值就可以達到同樣的目的;寬高比依然設定為4:3,寬度設定為400,在布局時修改常量值修改寬度,而高度則由 AutoLayout 引擎計算出來,不像之前直接設定長寬數值,其實之前也可以直接修改約束,但我不知道可以修改。除了還要設定內嵌的圖像視圖的約束,這就完了。

023.png

重新設定約束

通常我們這樣調整 anchor point 讓視圖不發生漂移:

subView.frame = frame
subView.layer.anchorPoint = CGPointMake(0.5, 1)
subView.frame = frame
事實上,用 constraint 的方式來實現這個手法就可以解決這個問題了:修改 anchor point 後,視圖的位置發生了移動,那麼補償這段移動就可以了。具體的計算方法可能要根據約束的條件來決定,這點不如 frame 時的簡單。不過,解決了不是。代碼裡用於初始化配置的函數實現了模塊優化和改名優化^_^,可能和上面的對不上號。
let centerYConstraint = superView.constraints.filter({$0.firstItem as? UIView == subView && $0.secondItem as? UIView == superView && $0.firstAttribute == .CenterY})
let subViewHeight = ....
let oldConstraintConstant = centerYConstraint.constant
subView.layer.anchorPoint = CGPointMake(0.5, 1)
//關鍵代碼:anchor point從(0.5,0.5)->(0.5,1),視圖會往上移動自身高度的一半,那麼補償這段高度
centerYConstraint.constant = subViewHeight/2 + oldConstraintConstant

這樣就解決了所有問題了。對了,transform 的問題不用解決,代碼中的其他改動也只是模塊化後的變動。

小總結

這次動畫的最大難點在於調整 anchor point,搞清楚機制後這個問題就很簡單了。 對於使用 frame 還是 AutoLayout,後者無疑是適應性布局的首選,雖然復雜了一些,坑也有不少,但值得入坑。

AutoLayout 不斷在改進,所以一些老問題就消失了,transform 的問題就是。實際上 transform 跟 AutoLayout 沒有交集,AutoLayout 只對約束有效,transform 並沒有修改約束條件,兩者互不干擾。而 transform 跟 frame 的關系也很有意思,transform 對視圖的 bounds 和 center 兩個屬性並沒有影響,只對 frame 有影響,自己可以在代碼中驗證一下。AutoLayout 和 frame,使用前者時最好不要直接修改 frame,雖然也能按照你的意願工作,但指不定不注意就掉坑裡了。

起初實現的時候沒有考慮那麼多,這類動畫還會有重排序、刪除和添加卡片的需求,後續有空會嘗試把這幾個功能補上,另外,有時間的話會考慮做成提供數據源後一鍵使用的樣子。

參考資料鏈接:

徹底理解 position 與 anchorPoint

How I Learned to Stop Worrying and Love Cocoa Auto Layout

Auto Layout Guide

WWDC15 Session 219: Mysteries of Auto Layout, Part 1

WWDC15 Session 219: Mysteries of Auto Layout, Part 2

Auto Layout Tutorial in iOS 9 Part 1: Getting Started

Auto Layout Tutorial in iOS 9 Part 2: Constraints

stackoverflow: How do I adjust the anchor point of a CALayer, when Auto Layout is being used?

Constraints & Transformations: How Auto Layout quietly became transform-friendly in iOS 8

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved