RxSwift และแอนิเมชั่นใน iOS
เผยแพร่แล้ว: 2022-03-11หากคุณเป็นนักพัฒนา iOS ที่ทำงาน UI ในระดับที่สมเหตุสมผลและหลงใหลในเรื่องนี้ คุณจะต้องชอบพลังของ UIKit เมื่อพูดถึงแอนิเมชั่น การสร้างภาพเคลื่อนไหว UIView นั้นง่ายเหมือนเค้ก คุณไม่จำเป็นต้องคิดมากเกี่ยวกับวิธีทำให้มันจาง หมุน ขยับ หรือย่อ/ขยายเมื่อเวลาผ่านไป อย่างไรก็ตาม ถ้าคุณต้องการเชื่อมโยงแอนิเมชันเข้าด้วยกัน และตั้งค่าการพึ่งพาระหว่างกัน รหัสของคุณอาจมีความละเอียดอ่อนและติดตามได้ยาก โดยมีการปิดและการเยื้องหลายระดับ
ในบทความนี้ ผมจะศึกษาวิธีการใช้พลังของเฟรมเวิร์กปฏิกิริยา เช่น RxSwift เพื่อทำให้โค้ดนั้นดูสะอาดตายิ่งขึ้น รวมทั้งอ่านและติดตามได้ง่ายขึ้น แนวคิดนี้มาถึงฉันเมื่อฉันทำงานในโครงการให้กับลูกค้า ลูกค้ารายนั้นเข้าใจ UI มาก (ซึ่งตรงกับความชอบของฉัน) พวกเขาต้องการให้ UI ของแอปทำงานในลักษณะเฉพาะ ด้วยทรานสิชั่นและแอนิเมชั่นที่ทันสมัยมากมาย หนึ่งในแนวคิดของพวกเขาคือการแนะนำแอพที่จะบอกเล่าเรื่องราวของแอพนั้นเกี่ยวกับอะไร พวกเขาต้องการเรื่องราวนั้นที่บอกเล่าผ่านลำดับของแอนิเมชั่นมากกว่าการเล่นวิดีโอที่แสดงผลล่วงหน้า เพื่อให้ปรับแต่งและปรับแต่งได้อย่างง่ายดาย RxSwift กลายเป็นตัวเลือกที่สมบูรณ์แบบสำหรับปัญหาแบบนั้น ฉันหวังว่าคุณจะตระหนักได้เมื่ออ่านบทความจบ
บทนำสั้น ๆ เกี่ยวกับการเขียนโปรแกรมเชิงโต้ตอบ
การเขียนโปรแกรมเชิงโต้ตอบกลายเป็นส่วนสำคัญและถูกนำมาใช้ในภาษาโปรแกรมสมัยใหม่ส่วนใหญ่ มีหนังสือและบล็อกมากมายที่อธิบายรายละเอียดที่ดีว่าทำไมการเขียนโปรแกรมเชิงโต้ตอบจึงเป็นแนวคิดที่ทรงพลังและช่วยส่งเสริมการออกแบบซอฟต์แวร์ที่ดีด้วยการบังคับใช้หลักการออกแบบและรูปแบบบางอย่างได้อย่างไร นอกจากนี้ยังให้ชุดเครื่องมือที่อาจช่วยให้คุณลดความยุ่งเหยิงของโค้ดได้อย่างมาก
ฉันต้องการจะพูดถึงแง่มุมหนึ่งที่ฉันชอบจริงๆ—ความง่ายในการที่คุณสามารถเชื่อมโยงการดำเนินการแบบอะซิงโครนัสและแสดงออกด้วยวิธีที่เปิดเผยและอ่านง่าย
เมื่อพูดถึง Swift มีสองเฟรมเวิร์กที่แข่งขันกันซึ่งช่วยให้คุณเปลี่ยนเป็นภาษาการเขียนโปรแกรมเชิงโต้ตอบ: ReactiveSwift และ RxSwift ฉันจะใช้ RxSwift ในตัวอย่าง ไม่ใช่เพราะมันดีกว่าแต่เพราะฉันคุ้นเคยกับมันมากกว่า ฉันจะถือว่าคุณผู้อ่านคุ้นเคยกับมันเป็นอย่างดีเพื่อที่ฉันจะได้เข้าถึงเนื้อของมันโดยตรง
แอนิเมชั่นการเชื่อมโยง: The Old Way
สมมติว่าคุณต้องการหมุนมุมมอง 180° แล้ว ค่อยๆ เลือนหายไป คุณสามารถใช้การปิดที่ completion
และทำสิ่งนี้:
UIView.animate(withDuration: 0.5, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { _ in UIView.animate(withDuration: 0.5) { self.animatableView.alpha = 0 } })
ใหญ่ไปหน่อยแต่ก็ยังโอเค แต่ถ้าคุณต้องการแทรกแอนิเมชั่นอีก 1 แอนิเมชั่นระหว่างนั้น ให้พูดว่าเลื่อนมุมมองไปทางขวาหลังจากที่มันหมุนแล้วและก่อนที่มันจะจางหายไปล่ะ ใช้วิธีเดียวกันนี้ คุณจะจบลงด้วยสิ่งนี้:
UIView.animate(withDuration: 0.5, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { _ in UIView.animate(withDuration: 0.5, animations: { self.animatableView.frame = self.animatableView.frame.offsetBy(dx: 50, dy: 0) }, completion: { _ in UIView.animate(withDuration: 0.5, animations: { self.animatableView.alpha = 0 }) }) })
ยิ่งคุณเพิ่มขั้นตอนมากเท่าไรก็ยิ่งเซและยุ่งยากมากขึ้นเท่านั้น จากนั้นหากคุณตัดสินใจที่จะเปลี่ยนลำดับของขั้นตอนบางอย่าง คุณจะต้องทำลำดับการตัดและวางที่ไม่สำคัญ ซึ่งมีโอกาสเกิดข้อผิดพลาดได้ง่าย
เห็นได้ชัดว่า Apple คิดอย่างนั้น—พวกเขาเสนอวิธีที่ดีกว่าในการทำเช่นนี้ โดยใช้ API แอนิเมชั่นที่ใช้คีย์เฟรม ด้วยวิธีการดังกล่าว โค้ดด้านบนสามารถเขียนใหม่ได้ดังนี้:
UIView.animateKeyframes(withDuration: 1.5, delay: 0, options: [], animations: { UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.33, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }) UIView.addKeyframe(withRelativeStartTime: 0.33, relativeDuration: 0.33, animations: { self.animatableView.frame = self.animatableView.frame.offsetBy(dx: 50, dy: 0) }) UIView.addKeyframe(withRelativeStartTime: 0.66, relativeDuration: 0.34, animations: { self.animatableView.alpha = 0 }) })
นั่นเป็นการปรับปรุงครั้งใหญ่ โดยมีข้อดีที่สำคัญคือ:
- โค้ดจะคงที่ไม่ว่าจะเพิ่มกี่ขั้นตอนก็ตาม
- การเปลี่ยนลำดับนั้นตรงไปตรงมา (โดยมีข้อแม้หนึ่งข้อด้านล่าง)
ข้อเสียของแนวทางนี้คือ คุณต้องคิดในแง่ของระยะเวลาสัมพัทธ์ และกลายเป็นเรื่องยาก (หรืออย่างน้อยก็ไม่ตรงไปตรงมามาก) ในการเปลี่ยนระยะเวลาสัมบูรณ์หรือลำดับของขั้นตอน แค่คิดเกี่ยวกับการคำนวณที่คุณจะต้องดำเนินการ และการเปลี่ยนแปลงประเภทใดที่คุณต้องทำกับระยะเวลาโดยรวมและระยะเวลาที่เกี่ยวข้อง/เวลาเริ่มต้นสำหรับแอนิเมชั่นแต่ละรายการ หากคุณตัดสินใจที่จะทำให้มุมมองลดลงภายใน 1 วินาทีแทนที่จะเป็นครึ่ง ที่สองในขณะที่รักษาทุกสิ่งทุกอย่างไว้เหมือนเดิม เช่นเดียวกัน หากคุณต้องการเปลี่ยนลำดับของขั้นตอน คุณจะต้องคำนวณเวลาเริ่มต้นที่สัมพันธ์กันใหม่
จากข้อเสีย ฉันไม่พบวิธีการใด ๆ ข้างต้นที่ดีพอ ทางออกที่ดีที่ฉันกำลังมองหาควรเป็นไปตามเกณฑ์ต่อไปนี้:
- รหัสจะต้องคงที่โดยไม่คำนึงถึงจำนวนขั้นตอน
- ฉันควรจะสามารถเพิ่ม/ลบหรือจัดลำดับแอนิเมชั่นใหม่และเปลี่ยนระยะเวลาได้อย่างอิสระโดยไม่มีผลข้างเคียงใดๆ กับแอนิเมชั่นอื่นๆ
แอนิเมชั่นการเชื่อมโยง: The RxSwift Way
ฉันพบว่าการใช้ RxSwift ทำให้ฉันบรรลุเป้าหมายทั้งสองได้อย่างง่ายดาย RxSwift ไม่ใช่เฟรมเวิร์กเดียวที่คุณสามารถใช้ทำสิ่งนั้นได้—เฟรมเวิร์กตามสัญญาใดๆ ที่ให้คุณรวมการดำเนินการแบบอะซิงโครนัสเป็นเมธอดที่สามารถเชื่อมโยงแบบวากยสัมพันธ์เข้าด้วยกันโดยไม่ต้องใช้บล็อกการสำเร็จ แต่ RxSwift ยังมีผู้ให้บริการอีกมากมายที่จะนำเสนอ ซึ่งเราจะพูดถึงในภายหลัง
นี่คือโครงร่างของวิธีที่ฉันจะทำ:
- ฉันจะรวมแอนิเมชั่นแต่ละอันไว้ในฟังก์ชันที่คืนค่าประเภท
Observable<Void>
ได้ - ที่สังเกตได้นั้นจะปล่อยองค์ประกอบเพียงตัวเดียวก่อนที่จะทำลำดับให้เสร็จ
- องค์ประกอบจะถูกปล่อยออกมาทันทีที่ภาพเคลื่อนไหวที่ปิดโดยฟังก์ชันเสร็จสิ้น
- ฉันจะเชื่อมโยงสิ่งที่สังเกตได้เหล่านี้เข้าด้วยกันโดยใช้ตัวดำเนินการ
flatMap
นี่คือลักษณะที่ฟังก์ชันของฉันสามารถมีลักษณะดังนี้:
func rotate(_ view: UIView, duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { view.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func shift(_ view: UIView, duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { view.frame = view.frame.offsetBy(dx: 50, dy: 0) }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func fade(_ view: UIView, duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { view.alpha = 0 }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } }
และนี่คือวิธีที่ฉันรวบรวมทั้งหมด:

rotate(animatableView, duration: 0.5) .flatMap { [unowned self] in self.shift(self.animatableView, duration: 0.5) } .flatMap { [unowned self] in self.fade(self.animatableView, duration: 0.5) } .subscribe() .disposed(by: disposeBag)
แน่นอนว่ามันมีโค้ดมากกว่าในการใช้งานครั้งก่อนๆ และอาจดูเหมือนใช้ทักษะมากเกินไปสำหรับลำดับแอนิเมชั่นง่ายๆ แต่ข้อดีคือ มันสามารถขยายเพื่อจัดการกับลำดับแอนิเมชั่นที่ค่อนข้างซับซ้อนได้ และอ่านง่ายมากเนื่องจาก ลักษณะการประกาศของไวยากรณ์
เมื่อคุณได้รับมือกับมันแล้ว คุณสามารถสร้างแอนิเมชั่นที่ซับซ้อนราวกับภาพยนตร์ได้ และมีตัวดำเนินการ RxSwift ที่มีประโยชน์มากมายให้คุณใช้งาน ซึ่งคุณสามารถใช้เพื่อทำสิ่งต่างๆ ให้สำเร็จได้ยากด้วยวิธีการใดๆ ที่กล่าวมาข้างต้น
ต่อไปนี้คือวิธีที่เราใช้ตัวดำเนินการ .concat
เพื่อทำให้โค้ดของฉันกระชับยิ่งขึ้น ซึ่งเป็นส่วนที่เชื่อมโยงแอนิเมชันเข้าด้วยกัน:
Observable.concat([ rotate(animatableView, duration: 0.5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
คุณสามารถแทรกการหน่วงเวลาระหว่างแอนิเมชั่นดังนี้:
func delay(_ duration: TimeInterval) -> Observable<Void> { return Observable.of(()).delay(duration, scheduler: MainScheduler.instance) } Observable.concat([ rotate(animatableView, duration: 0.5), delay(0.5), shift(animatableView, duration: 0.5), delay(1), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
ตอนนี้ สมมติว่าเราต้องการให้มุมมองหมุนจำนวนครั้งก่อนที่จะเริ่มเคลื่อนไหว และเราต้องการปรับแต่งอย่างง่ายดายว่าควรหมุนกี่ครั้ง
ขั้นแรก ฉันจะสร้างวิธีการที่ใช้ภาพเคลื่อนไหวการหมุนซ้ำอย่างต่อเนื่องและปล่อยองค์ประกอบหลังจากการหมุนแต่ละครั้ง ฉันต้องการให้การหมุนเหล่านี้หยุดลงทันทีที่กำจัดสิ่งที่สังเกตได้ ฉันสามารถทำสิ่งนี้:
func rotateEndlessly(_ view: UIView, duration: TimeInterval) -> Observable<Void> { var disposed = false return Observable.create { (observer) -> Disposable in func animate() { UIView.animate(withDuration: duration, animations: { view.transform = view.transform.rotated(by: .pi/2) }, completion: { (_) in observer.onNext(()) if !disposed { animate() } }) } animate() return Disposables.create { disposed = true } } }
จากนั้นแอนิเมชั่นที่สวยงามของฉันก็มีลักษณะดังนี้:
Observable.concat([ rotateEndlessly(animatableView, duration: 0.5).take(5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
คุณจะเห็นว่ามันง่ายเพียงใดในการควบคุมจำนวนครั้งที่มุมมองจะหมุน—เพียงแค่เปลี่ยนค่าที่ส่ง take
ยังโอเปอเรเตอร์เทค
ตอนนี้ ฉันต้องการนำการใช้งานของฉันไปใช้งานอีกขั้นหนึ่งโดยห่อแต่ละรายการหากฟังก์ชันแอนิเมชั่นที่ฉันสร้างไว้ในส่วนขยาย "ปฏิกิริยา" ของ UIView (เข้าถึงได้ผ่านส่วนต่อท้าย . .rx
) สิ่งนี้จะทำให้เป็นไปตามข้อตกลงของ RxSwift มากขึ้น โดยที่ฟังก์ชันเชิงโต้ตอบมักจะเข้าถึงได้ผ่านส่วนต่อท้าย .rx
เพื่อให้ชัดเจนว่าฟังก์ชันเหล่านี้ส่งคืนค่าที่สังเกตได้
extension Reactive where Base == UIView { func shift(duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { self.base.frame = self.base.frame.offsetBy(dx: 50, dy: 0) }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func fade(duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { self.base.alpha = 0 }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func rotateEndlessly(duration: TimeInterval) -> Observable<Void> { var disposed = false return Observable.create { (observer) -> Disposable in func animate() { UIView.animate(withDuration: duration, animations: { self.base.transform = self.base.transform.rotated(by: .pi/2) }, completion: { (_) in observer.onNext(()) if !disposed { animate() } }) } animate() return Disposables.create { disposed = true } } } }
ด้วยวิธีนี้ฉันสามารถรวมเข้าด้วยกันดังนี้:
Observable.concat([ animatableView.rx.rotateEndlessly(duration: 0.5).take(5), animatableView.rx.shift(duration: 0.5), animatableView.rx.fade(duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
จะไปจากที่นี่ที่ไหน
ตามที่บทความนี้แสดงให้เห็น โดยการปล่อยพลังของ RxSwift และเมื่อคุณมีพื้นฐานแล้ว คุณก็จะสนุกกับแอนิเมชั่นได้อย่างแท้จริง โค้ดของคุณสะอาดและอ่านง่าย และดูไม่เหมือน "โค้ด" อีกต่อไป คุณ "อธิบาย" ว่าแอนิเมชั่นของคุณถูกรวมเข้าด้วยกันอย่างไรและพวกมันก็มีชีวิตชีวาขึ้นมา! หากคุณต้องการทำอะไรที่ละเอียดกว่าที่อธิบายไว้ในที่นี้ คุณสามารถทำได้โดยการเพิ่มแอนิเมชั่นประเภทอื่นๆ คุณยังสามารถใช้ประโยชน์จากคลังเครื่องมือและเฟรมเวิร์กที่เติบโตขึ้นเรื่อยๆ ได้เสมอ ซึ่งพัฒนาโดยคนที่กระตือรือร้นในชุมชนโอเพ่นซอร์ส
หากคุณเป็นผู้แปลง RxSwift ให้ไปที่ที่เก็บ RxSwiftCommunity เป็นประจำ: คุณจะพบสิ่งใหม่ๆ ได้เสมอ!
หากคุณกำลังมองหาคำแนะนำ UI เพิ่มเติม ลองอ่าน วิธีใช้งานการออกแบบ iOS UI ที่สมบูรณ์แบบ Pixel โดยเพื่อน Toptaler Roman Stetsenko
Source Notes (การทำความเข้าใจพื้นฐาน)
- Wikipedia - Swift (ภาษาโปรแกรม)
- Lynda.com - RxSwift: รูปแบบการออกแบบสำหรับนักพัฒนา iOS
- We Heart Swift - พื้นฐาน UIView
- เชิงมุม - สังเกตได้