Untuk membuat pizza dari belahan, kami menggunakan dua UICollectionViewLayout
. Saya berbicara tentang bagaimana kami menulis tata letak untuk iOS, apa yang kami temui dan tolak.

Prototipe
Ketika kami mendapat tugas membuat antarmuka untuk pizza dari bagian, kami agak bingung. Saya menginginkannya indah, dan jelas, dan nyaman, dan besar, dan lebih banyak berinteraksi dan banyak lagi. Saya ingin melakukan yang keren.
Desainer mencoba pendekatan yang berbeda: kisi pizza, kartu horizontal dan vertikal, tetapi menetap di swipe setengah. Kami tidak tahu bagaimana mencapai hasil seperti itu, jadi kami mulai dengan eksperimen dan butuh dua minggu untuk membuat prototipe. Bahkan tata letak kasar bisa menyenangkan semua orang. Reaksi direkam pada video:
Cara Kerja UICollectionView
UICollectionView
adalah subkelas dari UIScrollView
, dan ini adalah UIView biasa, dengan bounds
berubah dari swipe. Memindahkannya .origin
, kami menggeser area yang terlihat, dan mengubah .size
Mempengaruhi skala.
Saat layar UICollectionView
membuat (atau menggunakan kembali) sel, dan aturan untuk menampilkannya dijelaskan dalam UICollectionViewLayout
. Kami akan bekerja dengannya.
Kemungkinan UICollectionViewLayout
besar, Anda dapat menentukan hubungan apa pun di antara sel. Misalnya, Anda dapat melakukan yang sangat mirip dengan apa yang dapat dilakukan iCarousel :

Pendekatan pertama
Mengubah tampilan saat menggerakkan layar membuatnya lebih mudah bagi saya untuk memahami tata letak tata letak.
Kita terbiasa dengan fakta bahwa sel bergerak di sekitar layar (persegi panjang hijau adalah layar ponsel):

Tetapi sebaliknya, layar ini bergerak relatif ke sel. Pohon-pohon masih, kereta ini bepergian:

Dalam contoh, bingkai sel tidak berubah, tetapi bounds
koleksi itu sendiri berubah. Origin
bounds
ini adalah contentOffset
diketahui oleh kami.
Untuk membuat tata letak, Anda harus melewati dua tahap:
- menghitung ukuran semua sel
- hanya terlihat di layar.
Layout sederhana seperti pada UITableView
Tata letak tidak bekerja dengan sel secara langsung. Sebagai gantinya, mereka menggunakan UICollectionViewLayoutAttributes
- ini adalah serangkaian parameter yang akan diterapkan ke sel. Frame
- yang utama, bertanggung jawab untuk posisi dan ukuran sel. Parameter lainnya: transparansi, offset, posisi di kedalaman layar, dll.

Untuk mulai UICollectionViewLayout
, UICollectionViewLayout
menulis UICollectionViewLayout
sederhana, yang mengulangi perilaku dari UITableView
: sel-sel menempati seluruh lebar, pergi satu demi satu.
4 langkah di depan:
- Hitung
frame
untuk semua sel dalam metode prepare
. - Kembalikan sel yang terlihat di
layoutAttributesForElements(in:)
metode layoutAttributesForElements(in:)
. - Kembalikan parameter sel dengan indeksnya di metode
layoutAttributesForItem(at:)
. Misalnya, metode ini digunakan saat memanggil metode koleksi scrollToItem (at :). - Kembalikan dimensi konten yang dihasilkan di
collectionViewContentSize
. Jadi kolektor akan mencari tahu di mana perbatasan yang dapat Anda gulir.
Pada sebagian besar perangkat, ukuran pizza akan menjadi 300 poin, lalu koordinat dan ukuran semua sel:

Saya membuat perhitungan di kelas yang terpisah. Ini terdiri dari dua bagian: ini menghitung semua frame dalam konstruktor, dan kemudian hanya memberikan akses ke hasil yang selesai:
class TableLayoutCache {
Kemudian di kelas tata letak Anda hanya perlu melewati parameter dari cache.
- Metode
prepare
memanggil perhitungan semua frame. - layoutAttributesForElements (dalam :) akan menyaring frame. Jika bingkai berpotongan dengan area yang terlihat, maka sel perlu ditampilkan: hitung semua atribut dan kembalikan ke array sel yang terlihat.
- layoutAttributesForItem (at :) - Menghitung atribut untuk sel tunggal.
class TableLayout: UICollectionViewLayout { override var collectionViewContentSize: CGSize { return cache.contentSize } override func prepare() { super.prepare() let numberOfItems = collectionView!.numberOfItems(inSection: section) cache = TableLayoutCache(itemSize: itemSize, collectionWidth: collectionView!.bounds.width) cache.recalculateDefaultFrames(numberOfItems: numberOfItems) } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { let indexes = cache.visibleRows(in: rect) let cells = indexes.map { (row) -> UICollectionViewLayoutAttributes? in let path = IndexPath(row: row, section: section) let attributes = layoutAttributesForItem(at: path) return attributes }.compactMap { $0 } return cells } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) attributes.frame = cache.defaultCellFrame(atRow: indexPath.row) return attributes } var itemSize: CGSize = .zero { didSet { invalidateLayout() } } private let section = 0 var cache = TableLayoutCache.zero }
Kami berubah sesuai dengan kebutuhan Anda
Kami telah menyortir tampilan tabel, tetapi sekarang kami perlu membuat tata letak yang dinamis. Pada setiap pergeseran jari, kita akan menghitung ulang atribut sel: mengambil bingkai yang sudah dihitung, dan mengubahnya menggunakan .transform
. Semua perubahan akan dilakukan dalam subkelas dari PizzaHalfSelectorLayout
.
Kami membaca indeks pizza saat ini
Untuk kenyamanan, Anda bisa melupakan contentOffset
dan menggantinya dengan jumlah pizza saat ini. Maka Anda tidak perlu lagi memikirkan koordinat, semua keputusan akan berada di sekitar nomor pizza dan tingkat perpindahannya dari pusat layar.
Dibutuhkan dua metode: satu mengubah contentOffset
menjadi nomor pizza, yang lain sebaliknya:
extension PizzaHalfSelectorLayout { func contentOffset(for pizzaIndex: Int) -> CGPoint { let cellHeight = itemSize.height let screenHalf = collectionView!.bounds.height / 2 let midY = cellHeight * CGFloat(pizzaIndex) + cellHeight / 2 let newY = midY - screenHalf return CGPoint(x: 0, y: newY) } func pizzaIndex(offset: CGPoint) -> CGFloat { let cellHeight = itemSize.height let proposedCenterY = collectionView!.screenCenterYOffset(for: offset) let pizzaIndex = proposedCenterY / cellHeight return pizzaIndex } }
Perhitungan contentOffset
untuk bagian tengah layar ditampilkan dalam extension
:
extension UIScrollView { func screenCenterYOffset(for offset: CGPoint? = nil) -> CGFloat { let offsetY = offset?.y ?? contentOffset.y let contentOffsetY = offsetY + bounds.height / 2 return contentOffsetY } }
Kami berhenti di pizza di tengah
Hal pertama yang perlu kita lakukan adalah menghentikan pizza di tengah layar. Metode targetContentOffset(forProposedContentOffset:)
bertanya di mana harus berhenti jika pada kecepatan saat ini ia akan berhenti di proposedContentOffset
.
Perhitungannya sederhana: lihat pizza mana yang akan dimasukkan ke ContContentOffset dan gulir sehingga pizza berada di tengah:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { let pizzaIndex = Int(self.pizzaIndex(offset: proposedContentOffset)) let projectedOffset = contentOffset(for: pizzaIndex) return projectedOffset }
UIScrollView
memiliki dua .normal
gulir: .normal
dan .fast
. .fast
lebih cocok untuk .fast
:.
collectionView!.decelerationRate = .fast
Tapi ada satu masalah: jika kita gulir sedikit saja, maka kita perlu tetap pizza, dan tidak melompat ke yang berikutnya. Tidak ada metode untuk mengubah kecepatan, jadi pantulan mundur, meskipun pada jarak kecil, tetapi dengan kecepatan yang sangat tinggi:

Perhatian, retas!
Jika sel tidak berubah, maka kami mengembalikan contentOffset
saat ini, sehingga gulir berhenti. Kemudian, kita sendiri akan menggulir ke tempat sebelumnya menggunakan scrollToItem
standar. Sayangnya, Anda juga harus menggulir secara tidak sinkron, sehingga kode dipanggil setelah return
, maka akan ada sedikit memudar selama animasi.
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { let pizzaIndex = Int(self.pizzaIndex(offset: proposedContentOffset)) let projectedOffset = contentOffset(for: pizzaIndex) let sameCell = pizzaIndex == currentPizzaIndexInt if sameCell { animateBackwardScroll(to: pizzaIndex) return collectionView!.contentOffset
Masalahnya hilang, sekarang pizza kembali dengan lancar:

Tingkatkan Pizza Tengah
Kami menceritakan tata letak saat bergerak
Hal ini diperlukan untuk membuat pizza pusat meningkat secara bertahap saat mendekati pusat. Untuk melakukan ini, Anda perlu menghitung parameter tidak sekali di awal, tetapi setiap kali di offset. Menyalakan hanya:
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
Sekarang, dengan setiap offset, metode prepare
dan layoutAttributesForElements(in:)
akan dipanggil. Jadi kita dapat memperbarui UICollectionViewLayoutAttributes
berkali-kali berturut-turut, dengan lancar mengubah posisi dan transparansi.
Dalam tata letak tabel, sel-sel berbaring di bawah satu sama lain dan koordinatnya dihitung sekali. Sekarang kita akan mengubahnya tergantung pada posisi relatif ke tengah layar. Tambahkan metode yang akan mengubahnya dengan cepat.
Dalam metode layoutAttributesForElements
, layoutAttributesForElements
perlu mendapatkan atribut dari superclass, memfilter atribut sel dan meneruskannya ke metode updateCells
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let elements = super.layoutAttributesForElements(in: rect) else { return nil } let cells = elements.filter { $0.representedElementCategory == .cell } self.updateCells(cells) }
Sekarang kita akan mengubah atribut sel dalam satu fungsi:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes])
Selama gerakan, kita perlu mengubah transparansi, ukuran, dan menjaga pizza di tengah.
Posisi sel relatif ke tengah layar mudah disajikan dalam bentuk normal. Jika sel berada di tengah, maka parameternya adalah 0, jika bergeser, maka parameter berubah dari -1 saat bergerak ke atas, ke 1 saat bergerak. Jika nilainya lebih jauh dari nol dari 1 / -1, maka ini berarti bahwa sel tidak lagi sentral dan telah berhenti berubah. Saya menyebut parameter skala ini:

Anda perlu menghitung perbedaan antara bagian tengah bingkai dan bagian tengah layar. Membagi perbedaan dengan konstanta, kami menormalkan nilai, dan min dan maks akan mengarah ke rentang dari -1 hingga +1:
extension PizzaHalfSelectorLayout { func scale(for row: Int) -> CGFloat { let frame = cache.defaultCellFrame(atRow: row) let scale = self.scale(for: frame) return scale.normalized } func scale(for frame: CGRect) -> CGFloat { let criticalOffset = PizzaHalfSelectorLayout.criticalOffsetFromCenter
Ukuran
Memiliki scale
dinormalisasi, Anda dapat melakukan apa saja. Perubahan dari -1 ke +1 terlalu kuat, mereka perlu dikonversi untuk ukuran. Misalnya, kami ingin ukurannya menurun hingga maksimum 0,6 dari ukuran pizza pusat:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) } }
.transform
relatif terhadap pusat sel. Sel pusat memiliki normScale = 0, ukurannya tidak berubah:

Transparansi
Transparansi dapat diubah melalui parameter alpha
. Nilai scale
yang kami gunakan dalam transform
juga cocok.
cell.alpha = scale
Sekarang pizza mengubah ukuran dan transparansi. Sudah tidak membosankan seperti di tabel biasa.

Membelah dua
Sebelum itu, kami bekerja dengan satu pizza: kami mengatur sistem referensi dari pusat, mengubah ukuran dan transparansi. Sekarang Anda perlu membagi dua.
Menggunakan satu koleksi untuk ini terlalu sulit: Anda harus menulis gesture handler Anda sendiri untuk setiap setengahnya. Lebih mudah membuat dua koleksi, masing-masing dengan tata letak sendiri. Hanya sekarang, bukannya pizza utuh, akan ada bagian.
Dua pengendali, satu wadah
Hampir selalu, saya memecah satu layar menjadi beberapa UIViewController
, masing-masing dengan tugasnya sendiri. Kali ini ternyata seperti ini:

- Pengontrol utama: semua bagian dirakit di dalamnya dan tombol "mix".
- Kontroler dengan dua wadah untuk separuh, tanda tangan pusat dan indikator gulir.
- Pengontrol dengan kolektor (putih kanan).
- Panel bawah dengan harga.
Untuk membedakan antara kiri dan kanan, saya mulai enum
, disimpan di tata letak di .orientation
.orientation:
enum PizzaHalfOrientation { case left case right func opposite() -> PizzaHalfOrientation { switch self { case .left: return .right case .right: return .left } } }
Kami menggeser separuh ke tengah
Tata letak sebelumnya berhenti melakukan apa yang kita harapkan: bagiannya mulai bergeser ke tengah koleksi mereka, bukan ke tengah layar:

Cara mengatasinya sederhana: Anda perlu menggeser sel secara horizontal ke tengah layar:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) switch orientation { case .left:
Jarak antar bagian segera dikontrol.

Sel offset
Mudah untuk memasukkan pizza bundar ke dalam kotak, dan untuk setengah Anda perlu setengah kotak:

Anda dapat menulis ulang perhitungan frame: membagi dua lebar, sejajarkan frame ke pusat berbeda untuk masing-masing setengah. Untuk mempermudah, cukup ubah contentMode
gambar di dalam sel:
class PizzaHalfCell: UICollectionViewCell { var halfOrientation: PizzaHalfOrientation = .left { didSet { imageView?.contentMode = halfOrientation == .left ? .topRight : .topLeft self.setNeedsLayout() } } }

Tekan pizza secara vertikal
Pizza menurun, tetapi jarak antara pusat mereka tidak berubah, kesenjangan besar muncul. Anda dapat mengompensasi mereka dengan cara yang sama seperti kami menyejajarkan bagian di tengah.
private func verticalOffset(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGFloat { let offsetFromCenter = offsetFromScreenCenter(element.frame) let vOffset: CGFloat = PizzaHalfSelectorLayout.verticalOffset( offsetFromCenter: offsetFromCenter, scale: scale) return vOffset } static func verticalOffset(offsetFromCenter: CGFloat, scale: CGFloat) -> CGFloat { return -offsetFromCenter / 4 * scale }
Akibatnya, semua kompensasi terlihat seperti ini:
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = horizontalOffset(for: element, scale: scale) let vOffset = verticalOffset (for: element, scale: scale) switch orientation { case .left:
Dan pengaturan selnya seperti ini:
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.alpha = scale cell.frame = centerAlignedFrame(for: cell, scale: scale) cell.transform = CGAffineTransform(scaleX: scale, y: scale) cell.zIndex = cellZLevel } }
Jangan bingung: pengaturan frame harus sebelum transformasi. Jika Anda mengubah urutan, hasil perhitungan akan sangat berbeda.
Selesai! Kami memotong separuh dan menyelaraskannya ke tengah:

Tambahkan keterangan
Header dibuat dengan cara yang sama seperti sel, hanya alih-alih UICollectionViewLayoutAttributes(forCellWith:)
Anda harus menggunakan konstruktor UICollectionViewLayoutAttributes(forSupplementaryViewOfKind:)
dan kembalikan bersama dengan parameter sel di layoutAttributesForElements(in:)
Pertama, kami menjelaskan metode untuk mendapatkan header dengan IndexPath
:
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = UICollectionViewLayoutAttributes( forSupplementaryViewOfKind: elementKind, with: indexPath) attributes.frame = defaultFrameForHeader(at: indexPath) attributes.zIndex = headerZLevel return attributes }
Penghitungan frame disembunyikan dalam metode defaultFrameForHeader
(nanti).
Sekarang Anda bisa mendapatkan IndexPath
sel yang terlihat dan menunjukkan IndexPath
untuk mereka:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { โฆ let visiblePaths = cells.map { $0.indexPath } let headers = self.headers(for: visiblePaths) updateHeaders(headers) return cells + headers }
Panggilan fungsi yang sangat panjang disembunyikan di headers(for:)
metode:
func headers(for paths: [IndexPath]) -> [UICollectionViewLayoutAttributes] { let headers: [UICollectionViewLayoutAttributes] = paths.map { layoutAttributesForSupplementaryView( ofKind: UICollectionView.elementKindSectionHeader, at: $0) }.compactMap { $0 } return headers }
zIndex
Sekarang sel-sel dan tanda tangan berada pada level "tinggi" yang sama, sehingga sel-sel itu dapat saling bertumpuk. Agar header selalu lebih tinggi, beri mereka zIndex
lebih besar dari nol. Misalnya, 100.
Kami memperbaiki posisi (sebenarnya tidak)
Tanda tangan yang diperbaiki di layar sedikit membingungkan. Anda ingin memperbaikinya, tetapi sebaliknya, terus bergerak bersama bounds
:

Semuanya sederhana dalam kode: kita mendapatkan posisi tanda tangan di layar dan menggesernya ke contentOffset
:
func defaultFrameForHeader(at indexPath: IndexPath) -> CGRect { let inset = max(collectionView!.layoutMargins.left, collectionView!.layoutMargins.right) let y = collectionView!.bounds.minY let height = collectionView!.bounds.height let width = collectionView!.bounds.width let headerWidth = width - inset * 2 let headerHeight: CGFloat = 60 let vOffset: CGFloat = 30 let screenY = (height - itemSize.height) / 2 - headerHeight / 2 - vOffset return CGRect(x: inset, y: y + screenY, width: headerWidth, height: headerHeight) }
Tinggi tanda tangan bisa berbeda, lebih baik untuk mempertimbangkannya dalam delegasi (dan cache di sana).
Tanda tangan animasi
Semuanya sangat mirip dengan sel. Berdasarkan scale
saat ini, Anda dapat menghitung transparansi sel. Offset dapat diatur melalui .transform
, sehingga prasasti akan bergeser relatif ke bingkai:
func updateHeaders(_ headers: [UICollectionViewLayoutAttributes]) { for header in headers { let scale = self.scale(for: header.indexPath.row) let alpha = 1 - abs(scale) header.alpha = alpha let translation = 20 * scale header.transform = CGAffineTransform(translationX: 0, y: translation) } }

Optimalkan
Setelah menambahkan tajuk, kinerja dicelupkan secara drastis. Itu terjadi karena kami menyembunyikan tanda tangan, tetapi masih mengembalikannya ke UICollectionViewLayoutAttributes
. Dari sini, tajuk ditambahkan ke hierarki, berpartisipasi dalam tata letak, tetapi tidak ditampilkan. Kami hanya menunjukkan sel-sel yang bersinggungan dengan bounds
saat ini, dan header perlu disaring oleh alpha
:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { โฆ let visibleHeaders = headers.filter { $0.alpha > 0 } return cells + visibleHeaders }
Kami setuju dengan tanda tangan pusat (resep asli)
Kami melakukan pekerjaan dengan baik, tetapi ada kontradiksi di antarmuka - jika Anda memilih dua bagian yang identik, maka mereka berubah menjadi pizza biasa.
Kami memutuskan untuk membiarkannya, tetapi dengan benar memproses kondisinya, menunjukkan bahwa itu adalah pizza biasa. Tugas baru kami adalah menunjukkan satu label di tengah untuk pizza yang identik, dan bersembunyi di tepinya.

Layout saja terlalu sulit untuk dipecahkan, karena prasasti tersebut berada di persimpangan dua kolektor. Akan lebih mudah jika controller, yang berisi kedua koleksi, mengkoordinasikan pergerakan semua tanda tangan.
Saat menggulir, kami meneruskan indeks saat ini ke controller, itu mengirimkan indeks ke bagian yang berlawanan. Jika indeks cocok, maka itu menunjukkan judul pizza asli, dan jika berbeda, maka tanda tangan untuk setiap setengah terlihat.
Cara membuat layout Anda
Bagian tersulit adalah mencari cara memasukkan ide Anda ke dalam komputasi. Misalnya, saya ingin pizza bergulir seperti drum. Untuk memahami masalahnya, saya melalui 4 langkah biasa:
- Saya menggambar beberapa negara.
- Saya mengerti bagaimana elemen terkait dengan posisi layar (elemen bergerak relatif ke tengah layar).
- Variabel yang dibuat yang nyaman untuk bekerja dengan (pusat layar, bingkai pizza pusat, skala).
- Saya datang dengan langkah-langkah sederhana, yang masing-masing dapat diverifikasi.
Negara dan animasi mudah digambar di Keynote. Saya mengambil tata letak standar dan menggambar dua langkah pertama:

Video berubah menjadi seperti ini:

Butuh tiga perubahan:
- Alih-alih frame dari cache, kami akan mengambil
centerPizzaFrame
. - Dengan menggunakan
scale
baca offset dari bingkai ini. Hitung ulang zIndex
.
func centerAlignedFrame(for element: UICollectionViewLayoutAttributes, scale: CGFloat) -> CGRect { let hOffset = self.horizontalOffset(for: element, scale: scale) let vOffset = self.verticalOffset (for: element, scale: scale) switch self.pizzaHalf { case .left:
Setelah sel tumpang tindih, maka Anda perlu mengaturnya dengan urutan yang benar zIndex
. Idenya adalah ini: semakin dekat sel ke pizza pusat, semakin dekat ke layar, dan semakin besar zIndex
.
private func updateCells(_ cells: [UICollectionViewLayoutAttributes]) { for cell in cells { let normScale = self.scale(for: cell.indexPath.row) let scale = 1 - PizzaHalfSelectorLayout.scaleFactor * abs(normScale) cell.alpha = 1
Kemudian, jika sel ketiga adalah arus, maka ternyata seperti ini:
row: zIndex` 0: 0 1: 1 2: 2 3: 10 โ 4: 5 5: 4 6: 3 7: 2 8: 1 9: 0
Saya tidak mendapatkan tata letak seperti itu dalam produksi, untuk ini saya akan memerlukan gambar dengan latar belakang transparan.
Tanggal rilis
Membuat antarmuka seperti itu, kami terlibat dalam percobaan kecil: untuk pertama kalinya kami menulis tata letak yang tidak biasa, dan kemudian menambahkan transisi interaktif ke layar kartu. Eksperimen itu membuahkan hasil: setengah pizza meledak menjadi penjualan teratas dan menempati posisi kedua, hanya kalah dari pepperoni klasik.
Tentu saja, produksi membutuhkan lebih banyak pekerjaan:
- potong gambar pizza menjadi dua,
- , : , ,
- ,
- ,
- -,
- ยซยป,
- Voice Over.
:
github , .
UICollectionViewLayout
, A Tour of UICollectionView
, .