Custom layout cho UICollectionView ???

brian
10 min readJul 2, 2018

--

Chào mọi người ❤ Đã lâu lắm rồi mình mới ngồi viết một bài mới. Mình gần như quên luôn mục tiêu từ đầu lúc bắt đầu viết những bài đầu tiên :))) Chắc hẳn là do quá lười mà nên :’(

Chủ đề hôm nay mình muốn đề cập tới là một chủ đề khá thú vị và liên quan trực tiếp đến UICollectionView, đó chính là làm sao để custom một layout theo sở thích của mình :D. Nếu bạn đã quen làm việc với UICollectionView thì sẽ biết nó “ngon” như thế nào :3 Có thể kể tới như khả năng custom vô hạn, built-in animation và trong những năm gần đây, cứ qua mỗi mùa WWDC, thì ta lại thấy UICollectionView được buff rất nhiều thứ để cải thiện performance.

Trong phạm vi bài viết này mình sẽ không nói chi tiết cách implement một custom layout mà chỉ nói tới các khái niệm và lý thuyết quan trọng cần biết. Mình sẽ đính kèm một số hướng dẫn cụ thể để implement ở cuối bài viết.

Như các bạn đã biết, thì khác với UITableView, thì UICollectionView tách biệt phần layout và phần datasource riêng biệt. Phần layout sẽ được quản lý thông qua layout object (UICollectionViewLayout) của UICollectionView, còn datasource sẽ cung cấp views để layout theo các thông tin layout object cung cấp; còn đối với UITableView thì nó chỉ có 1 layout duy nhất không thể custom, chỉ có thể tuỳ biến một thuộc tính thông qua delegate. Đó là điểm hay ở UICollectionView, mình có thể làm đủ mọi thể loại layout với nó, ngang, dọc, grid, dạng stack, rồi có khi cả hình tròn =)))

Từ iOS 6, Apple có cung cấp cho chúng ta một concrete class của UICollectionViewLayout là UICollectionViewFlowLayout. Flow layout được dùng để implement các layout dạng grid, đây gần như là use-case phổ biến nhất của collection views trong iOS, ta có thể thấy ở phần lớn các app mặc định của iOS, như Photos chẳng hạn.

Flow layout

Flow layout là một dạng line-based layout, có nghĩa là layout object sẽ sắp xếp các items trên một hàng, và nó sẽ tính toán làm sao để có thể chứa càng nhiều items trên hàng đó càng tốt. Tới khi không còn thể nhét thêm bất cứ item nào nữa thì nó sẽ tạo ra một hàng mới và bắt đầu lặp lại công việc này cho đến khi hết tất cả items. Nếu có nhiều sections thì sẽ layout cho đến khi hết items trong section đó và bắt đầu section mới.

Flow layout chỉ có chúng ta scroll theo một hướng (1 scroll direction), vertical hoặc horizontal. Nếu scroll theo vertical, items sẽ được layout từ trái sang phải và nếu theo horizontal thì sẽ là từ trên xuống dưới.

Trong khi layout, các items trên cùng một hàng sẽ được canh chính giữa hàng đó.

Ngoài ra, Flow layout còn cho phép chúng ta tuỳ chỉnh thêm một số thuộc tính, ví dụ như: line spacing và inter-item spacing hay insets của section. Xem thêm: Customizing the Flow Layout Attributes.

Nếu Flow layout đã gần như cung cấp đầy đủ những thứ ta cần thì khi nào chúng ta cần custom layout:

  • Nếu không muốn 1 layout dạng line-based hay grid (hình tròn như đã nói ở trên :)))
  • Muốn over control tất cả các elements trong layout, thay đổi chúng một cách tuỳ thích. Thêm các supplementaries hay decoration views và đặt chúng ở mọi vị trí, chứ không chỉ header hay footer views mà Flow layout supported sẵn.
  • Hoặc đơn giản là THÍCH :3 Có cảm giác sở hữu được những cái mình đang làm và hiểu tường tận cách một layout object làm việc.

CORE LAYOUT PROCESS

Layout object của collection view sẽ đảm nhiệm việc quản lý layout process. Khi collection view cần thông tin của layout, nó sẽ hỏi layout object cung cấp những thông tin đó. Layout object sẽ dụng một số thông tin của data source để layout. Ví dụ như: numberOfSections, numberOfItemsInSection,…

Custom layout, hay nói đúng hơn là subclass UICollectionViewLayout để tuỳ biến, sẽ có một số bước chính, cũng như các methods đi kèm như sau:

1.prepareLayout: đây là bước đầu tiên trong layout process. Đây là lúc chúng ta phải thực hiện một số tính toán để xác định được vị trí của các elements trong layout. Có thể hiểu là tính toán frame cho từng element, giống hệt như cách layout các view trong supperview theo kiểu frame-based (manual layout). Đại khái thì sau khi tính toán chúng ta sẽ có được attributes của từng elements (object của class UICollectionViewLayoutAttributes). Kết thúc bước này, chúng ta tối thiểu phải có được content size cho collection view. Ví dụ: có 2 item và sroll direction là vertical và chỉ có 1 column, item 1 có frame là (0,0,100,100), item 2 (0,100,100,100). Thì content size sẽ là tổng của 2 item trên (100,200).

:)) Đại khái vậy chứ mình vẽ hơi sida.

2.collectionViewContentSize: từ content size được tính toán ở bước 1. Bước này chúng ta chỉ cần trả về kết quả đó. Collection view sử dụng content size để config cho scroll view. Nếu content size lớn hơn bounds của collection view thì sẽ scrollable để xem hết được content.

3.layoutAttributesForElementsInRect: lúc scroll collection view, thì collection view sẽ yêu cầu chúng ta cung cấp các attributes của các elements nằm trong một vùng rectangle nào đó của layout (như hình dưới). Để dễ hiểu thì chúng ta có thể giả sử đây là visible area (vùng được hiển thị trên màn hình). Theo như Apple, thì vùng rectangle này có thể là visible area hoặc lơn hơn nếu có các cơ chế prefetch hoặc có thể là bất cứ vùng nào :v (nó cần layout sẵn cho vùng nào đó để tối ưu scroll performance chẳng hạn).

Core layout process

Tuỳ vào chiến lược của từng người, thì sẽ cung cấp attributes một cách khác nhau. Một số người sẽ bắt đầu cung cấp attribues (ở đây hiểu là tạo ra instance của UICollectionViewLayoutAttributes, ở bước prepare chỉ cần tính toán sao cho có được content size) chỉ khi thật sự cần, ví dụ như scroll xuống, thì attribues cho elements chưa được tính, thì tới lúc đó sẽ tính. Còn với bản thân mình, nếu sống lượng elements không quá nhiều (hàng triệu, chục triệu) hay số lượng không thay đổi thường xuyên thì mình sẽ cung cấp attributes cho tất cả các elements đó ở bước prepareLayout. Ban đầu chậm hơn một tí, nhưng đổi lại cảm giác scroll mượt mà hơn.

Sau khi các bước trên hoàn tất, đồng nghĩa layout process kết thúc, attributes của tất cả các elements sẽ được giữ nguyên cho đến khi nhận được bất kỳ lệnh invalidate layout nào được gọi. Gọi invalidateLayout đồng nghĩa với việc làm layout process bắt đầu lại từ đầu, bắt đầu với prepareLayout. Event invalidateLayout có thể xảy ra thậm chí khi chúng ta scroll. Bất cứ khi nào chúng ta scroll trên collection view, collection view sẽ gọi shouldInvalidateLayoutForBoundsChange để kiểm tra layout hiện tại vẫn còn đúng không hay nó phải cần tính toán lại. Khi viết custom layout thì đây là phương thức quan trọng cần phải biết, để có thể làm một số thứ như: implement sticky header như của table view, hoặc thay đổi vị trí của view để nó luôn hiển thị trong visible area. Chúng ta cần override hàm này và return YES. Như vậy mỗi lần scroll, chúng ta có thể update lại layout để đúng ý đồ.

Embedded index view

Như ví dụ trên, mình muốn có một index view luôn luôn hiển thị trong vùng visible (cụ thể là cạnh phải) của collection view, thì mỗi lần scroll mình phải invalidate lại layout và update lại cho index view đó :D

Ngoài ra, custom layout cũng có một số trò vui vẻ cho chúng ta làm. Khi insert hoặc delete một element nào, collection view sẽ hỏi layout object update lại layout, vì đơn giản collection view cần biết một số thông tin ví dụ như khi insert/delete/move một cell thì postion của cell đó là ở đâu và các cells khác sẽ đổi position qua đâu (giống như gọi invalidateLayout, sẽ chạy lại layout process từ đầu). Đối với trường hợp move 1 item, thì collection view biết được vị trí đầu và vị trí cuối của item đó rồi sử dụng built-in animation của collection view và mọi việc được làm hoàn toàn tự động mà chúng ta k cần phải làm thêm bất cứ thứ gì. Với trường hợp insert thì collection không biết vị trí đầu của nó là ở đâu, còn delete collection view không biết vị trí cuối của nó ở đâu để animate. Khi custom một layout, nó cho phép chúng ta cung cấp các thông tin đó cho collection view để collection view có thể animate và UI/UX của app sẽ nhìn đẹp mắt hơn.

Insert item

Như ví dụ minh hoạ ở trên, chúng ta provide vị trí đầu của một inserted item là ở chính giữa collection view, như vậy thì inserted item sẽ được animate từ giữa và đi về góc phải dưới cùng ❤. Với trường hợp delete thì mọi thứ tương tự như vậy.

Chúng ta sẽ cần phải override một số methods và trả về attributes cho inserted/deleted items.

Custom layout có thể thay đổi được scrolling behavior của collection view để nâng cao UX. Khi user bắt đầu touch để scroll, scroll view của collection có thể tính toán được vị trí (hay content offset) khi dừng scroll. Layout object cũng cấp cho chúng ta method targetContentOffsetForProposedContentOffset:withScrollingVelocity: để cho chúng ta cơ hội config lại content offset (return target content offset) để hiển thị đúng ý đồ khi scroll dừng hẳn. Ví dụ: chúng ta muốn item được hiển thị chính giữa trong visible area của collection view (hình bên dưới).

Invalidating process

Như có đề cập ở trên, nếu return shouldInvalidateLayoutForBoundsChange bằng YES thì mỗi khi bounds của collection view thay đổi thì sẽ cần update lại layout và trong suốt quá trình scroll thì sẽ invalidate layout liên tục :((((( Việc này cũng đồng nghĩa với việc layout process sẽ lặp lại liên tục => lag banh xác… Nhưng may mắn thay, Apple còn biết nghĩ đến chúng ta. Apple có provide một cách thức đó là sử dụng UICollectionViewLayoutInvalidationContext để tối ưu cho việc này. Thay vì mặc định, mỗi lần scroll, chúng ta phải tính toán lại toàn bộ layout, thì thay vào đó chỉ cần tính những phần cần thiết (vd: tính lại position cho sticky header).

Mà nói đi cũng nói lại, khổ nỗi thằng Apple cho cho cái invalidation context để sài là vậy mà nó cũng chẳng nó gì cụ thể về cách thức hoạt động cũng như cách sử dụng … !!! ?? :) ?? oi1wiod1hi9coip1bpcu1uc…..

Chán!!! Sau một hồi thử Google, thì cũng khá nhiều người đồng ý rằng tài liệu về cái này quá mập mờ :(. Lại tiếp qúa trình mày mò, rồi mình bắt đầu đọc hết API của UICollectionViewLayoutInvalidationContext và rồi UICollectionViewLayout và work around một tí thì phát hiện, mỗi lần scroll thì thứ tự gọi của layout sẽ như sau:

  1. shouldInvalidateLayoutForBoundsChange
  2. invalidationContextForBoundsChange
  3. invalidateLayoutWithContext
  4. prepareLayout.

Ở bước số 2, nó yêu cầu chúng ta return về một invalidation context và mình đoán đây sẽ là bước chúng ta config cho invalidation context đó. Give a try!!! Chúng ta sẽ mark các elements cần update layout bằng cách invalidateItemsAtIndexPaths / invalidateSupplementaryElementsOfKind / invalidateDecorationElementsOfKind (các hàm gọi bởi object context). Các elements được marked need to update layout sẽ gọi layoutAttributesForItemAtIndexPath / layoutAttributesForSupplementaryViewOfKind / layoutAttributesForDecorationViewOfKind tương ứng để yêu cầu attributes mới (tính toán lại position dựa trên content offset) để update layout và không cần gọi lại prepareLayout. BINGO!!! DONE!!!

// NOTE: mark need to update có thể làm ở bước 3 cũng được :D

Note: thêm một dòng note nho nhỏ là lúc call invalidateLayout, thì layout update sẽ không diễn ra ngay lập tức mà phải đợi tới next update cycle (sẽ nói chi tiết trong bài viết khác). Cơ chế hoạt động khá giống với setNeedsDisplay.

References:

Creating Custom Layouts — Apple

Collection View Programming Guide For iOS — Apple

UICollectionView Custom layout tutorial — Ray Wenderlich

Custom UICollectionViewLayout Tutorial — Ray Wenderlich

Video series: Custom Collection View Layouts

Mình xin cảm ơn các bạn đã dành thời gian để đọc hết những dòng mình viết ra. Hẹn mọi người vào bài viết sau. Nếu có bất cứ sai sót gì trong bài thì mọi người hãy comment để cho mình biết và chỉnh sửa nhé ❤

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

brian
brian

Written by brian

a software engineer who does software engineering

Responses (3)

Write a response