Gulir tanpa batas dengan spanduk, atau Bagaimana melakukan dengan tiga tampilan


Setiap pengembang untuk platform seluler selalu dihadapkan dengan tugas yang tidak dapat diselesaikan dengan satu cara. Selalu ada beberapa cara, beberapa cepat, beberapa rumit, dan masing-masing memiliki kelebihan dan kekurangan.

Scroll tak berujung / siklik di iOS tidak dapat diimplementasikan menggunakan alat standar, Anda harus pergi ke trik yang berbeda. Dalam artikel ini saya akan memberi tahu Anda opsi mana untuk menyelesaikan masalah yang ada di permukaan dan opsi mana yang akhirnya kami terapkan.

Tugas


Itu perlu untuk membuat bergulir siklik tak berujung dengan unsur-unsur dalam bentuk gambar, judul dan subtitle yang disiapkan. Input: gambar pusat diindentasi dari layar sebesar 16.0 poin. Di sisi gambar tengah, "telinga" menonjol. Dan jarak antara spanduk adalah 8,0 poin.


Kami mempelajari apa yang telah dilakukan oleh rekan kerja



Spanduk Dodo tidak siklis, spanduk pusat selalu bersebelahan dengan tepi kiri pada jarak tertentu.

Auto.ru - spanduk tidak siklis, jika Anda menggesek, maka spanduk dibalik untuk waktu yang sangat lama.

Ozon - spanduk bersifat siklis, tetapi tidak dapat dikontrol dengan sentuhan: begitu arah gesek ditentukan, gambar tidak lagi dapat dihentikan.

Wildberries - spanduk tidak siklis, pemusatan terjadi, penyelesaian panjang dari animasi gulir.

Keinginan terakhir:

  • Spanduk harus dipusatkan.
  • Menggulir harus diakhiri tanpa menunggu lama untuk animasi.
  • Pengaturan spanduk: harus ada kemampuan untuk mengontrol animasi gulir dengan jari Anda.

Opsi implementasi


Ketika tugas baru muncul yang belum diselesaikan, ada baiknya mempertimbangkan solusi dan pendekatan yang ada.

UICollectionView


Bertindak di dahi, Anda bisa sampai pada opsi untuk membuat UICollectionView. Kami membuat jumlah elemen Int.maxdan selama inisialisasi kami menunjukkan tengah, dan saat memanggil metode di dataSource- func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. Kami akan mengembalikan elemen yang sesuai, dengan asumsi bahwa elemen nol adalah ini Int.max / 2. Monster seperti itu dengan banyak fitur, seperti UICollectionView, tidak pantas digunakan untuk tugas sederhana kita.

UIScrollView dan (n + 2) UIView


Masih ada opsi di mana UIScrollView dibuat, benar-benar semua spanduk ditempatkan di atasnya, dan ditambahkan ke awal dan akhir oleh spanduk. Ketika kami menyelesaikannya sampai akhir, kami mengubah offset secara tidak terlihat untuk pengguna dan kembali ke elemen pertama. Dan ketika bergulir kembali, kami melakukan yang sebaliknya. Akibatnya, dengan sejumlah besar elemen, sekelompok tampilan akan dibuat tanpa menggunakannya kembali.


Sumber

Jalannya sendiri


Kami memutuskan untuk membuat UIScrollView + tiga UIView. UIView ini akan digunakan kembali. Pada saat menggulir, kami akan kembali contentOffsetke spanduk pusat dan mengganti konten ketiga UIView. Dan kemudian Anda harus mendapatkan komponen ringan yang menutup tugas ini.

Namun, ada kekhawatiran bahwa spoofing konten selama pengguliran akan terlihat oleh pengguna. Kami belajar tentang ini selama implementasi.

Penerapan


Mempersiapkan UIScrollView dan Tiga UIImageView


Buat pewaris UIView, tempatkan UIScrollViewtiga di atasnya UIImageView:

final class BannersView: UIView {
    private let scrollView = UIScrollView()

    private let leftItemView = UIImageView()
    private let centerItemView = UIImageView()
    private let rightItemView = UIImageView()

    init() {
        super.init(frame: .zero)
        self.setup()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setup() {
        self.addSubview(self.scrollView)
        self.setupScrollView()

        let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
        imageViews.forEach(self.scrollView.addSubview)
    }
}

Tambahkan implementasi metode dengan pengaturan scrollView:

  • decelerationRate- Parameter ini menunjukkan seberapa cepat animasi gulir akan melambat. Dalam kasus kami, ini yang terbaik .fast.
  • showsHorizontalScrollIndicator - parameter ini bertanggung jawab untuk menampilkan scrollbar horizontal:

    private func setupScrollView() {
        self.scrollView.decelerationRate = .fast
        self.scrollView.showsHorizontalScrollIndicator = false
    }
    

Setelah pengaturan dasar, kita bisa melakukan tata letak dan penempatan ImageView:

override func layoutSubviews() {
    super.layoutSubviews()
    self.scrollView.frame = self.bounds

    let horizontalItemOffsetFromSuperView: CGFloat = 16.0
    let spaceBetweenItems: CGFloat = 8.0
    let itemWidth = self.frame.width - horizontalItemOffsetFromSuperView * 2
    let itemHeight: CGFloat = self.scrollView.frame.height

    var startX: CGFloat = 0.0

    let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
    imageViews.forEach { view in
        view.frame.origin = CGPoint(x: startX, y: 0.0)
        view.frame.size = CGSize(width: itemWidth, height: itemHeight)
        startX += itemWidth + spaceBetweenItems
    }

    let viewsCount: CGFloat = 3.0
    let contentWidth: CGFloat = itemWidth * viewsCount + spaceBetweenItems * (viewsCount - 1.0)
    self.scrollView.contentSize = CGSize(width: contentWidth, height: self.frame.height)
}

Tambahkan ke UIImageViewgambar yang akan kami tarik dari situs penghasil gambar https://placeholder.com :

    let imageURLs = ImageURLFactory.makeImageURLS()
    imageViews.enumerated().forEach { key, view in
        view.setImage(with: imageURLs[key])
    }

Hasil dari langkah persiapan pertama:


Tengahkan gambar saat menggulir


Untuk mengontrol gulir yang akan kita gunakan UIScrollViewDelegate. Kami setupmenetapkan delegasi untuk ke metode UIScrollView, dan juga mengatur contentInsetgambar pertama dan terakhir untuk memiliki lekukan di samping.

self.scrollView.contentInset = UIEdgeInsets(top: 0.0, left: 16.0, bottom: 0.0, right: 16.0)
self.scrollView.delegate = self

Kami membuat extensionuntuk kami BannersViewsalah satu metode. Metode delegasi func scrollViewWillEndDraggingdipanggil ketika pengguna berhenti menggulir. Dalam metode ini, kami tertarik targetContentOffset- ini adalah variabel yang bertanggung jawab untuk offset gulir akhir (titik di mana gulir berhenti).


extension BannersView: UIScrollViewDelegate {

  func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    let gap: CGFloat = self.centerItemView.frame.width / 3

    let targetRightOffsetX = targetContentOffset.pointee.x + self.frame.width
    if (self.rightItemView.frame.minX + gap) < targetRightOffsetX {
      targetContentOffset.pointee.x = self.rightItemView.frame.midX - self.frame.midX
    }
    else if (self.leftItemView.frame.maxX - gap) > targetContentOffset.pointee.x {
      targetContentOffset.pointee.x = self.leftItemView.frame.midX - self.frame.midX
    }
    else {
      targetContentOffset.pointee.x = self.centerItemView.frame.midX - self.frame.midX
    }
  }

}

gap- ini adalah jarak di mana kita akan menganggap bahwa pandangan adalah pusat. Jika sepertiga dari lebar gambar oranye ditampilkan di layar, maka kita akan mengatur offset akhir sehingga gambar oranye berada di tengah.


targetRightOffsetX - Poin ini akan membantu menentukan apakah pandangan yang benar adalah pusat.


Hasil implementasi dari metode ini:


Kelola offset saat menggulir


Sekarang, tepat saat menggulir, kita akan berubah contentOffset, kembali ke tengah layar tampilan tengah. Ini akan memungkinkan pengguna untuk membuat ilusi bergulir tanpa batas tanpa disadari.

Tambahkan metode delegasi func scrollViewDidScroll(_ scrollView: UIScrollView), disebut ketika contentOffsety berubah UIScrollView.


func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard
            self.leftItemView.frame.width > 0,
            self.centerItemView.frame.width > 0,
            self.rightItemView.frame.width > 0
            else {
        return
    }

    let gap: CGFloat = self.centerItemView.frame.width / 3
    let spacing: CGFloat = 8.0

    let currentRightOffset: CGFloat = scrollView.contentOffset.x + self.frame.width + scrollView.contentInset.left

    if (self.rightItemView.frame.maxX - gap) < currentRightOffset {
        scrollView.contentOffset.x -= self.centerItemView.frame.width + spacing
    } else if (self.leftItemView.frame.minX + gap) > scrollView.contentOffset.x {
        scrollView.contentOffset.x += self.centerItemView.frame.width + spacing
    }
}

gap- ini adalah jarak dari mana kita akan menentukan kebutuhan perpindahan contentOffset. Kami menghitung titik untuk rightItemView:, self.rightItemView.frame.maxX — gapsetelah persimpangan yang kita akan bergeser contentOffset. Misalnya, jika rightItemView100.0 poin tetap digulir ke tampilan penuh , maka kami menggeser contentOffsetmundur, dengan lebar satu spanduk, dengan mempertimbangkan jarak antara spanduk (spasi), sehingga ada centerItemViewdi tempat rightItemView. Demikian pula, kita lakukan untuk leftItemView: kita menghitung titik, setelah persimpangan yang akan kita ubah contentOffset.


Tambahkan metode func set(imageURLs: [URL])untuk mengekspos data untuk ditampilkan di luar. Di sana kami mentransfer sebagian kode dari setup.

Dan kami juga akan menambahkan baris sehingga ketika Anda mengatur konten itu centerItemViewlangsung terpusat. horizontalItemOffsetFromSuperViewkami sudah menggunakannya layoutSubviews, jadi kami memasukkannya ke dalam konstanta dan menggunakannya lagi.

func set(imageURLs: [URL]) {
    //    ImageView
    let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
    imageViews.enumerated().forEach { key, view in
        view.setImage(with: imageURLs[key])
    }
    //    ,  centerItemView   
    self.scrollView.contentOffset.x = self.centerItemView.frame.minX - Constants.horizontalItemOffsetFromSuperView
}

Kami akan memanggil metode ini di luar di UIViewController.viewDidAppear. Atau Anda dapat memindahkan penyelarasan pertama layoutSubviews, tetapi verifikasi bahwa ini akan dilakukan hanya ketika mengubah bingkai seluruh tampilan. Untuk menunjukkan pekerjaan, kami akan menggunakan metode pertama:


Jadi ... Dengan scroll yang tajam, centering pecah.


Faktanya adalah bahwa dengan scrolling yang kuat itu diabaikan targetContentOffset. Mari kita tingkatkan contentInset, setelah itu semuanya bekerja dengan benar. Pandangan sentral akan selalu terpusat.

self.scrollView.contentInset = UIEdgeInsets(top: 0.0, left: 300.0, bottom: 0.0, right: 300.0)


Kami mengganti konten


Tugasnya adalah untuk contentOffsetmengganti konten tampilan di offset pada saat bersamaan. Saat menggulir ke kanan, gambar kanan akan menjadi pusat, pusat akan menjadi kiri, dan kiri akan kanan. 1 - 2 - 3 | 2 - 3 - 1.

Untuk kenyamanan, buat ViewModel:

struct BannersViewModel {
    //     3     
    let items: [URL] = ImageURLFactory.makeImageURLS()
}

Untuk memeriksa elemen mana yang sekarang ada di tengah, tambahkan variabel ke BannersViewdan variabel dengan konten untuk setiap tampilan:

    private var currentCenterItemIndex: Int = 0

    private var viewModel: BannersViewModel?

    private var leftItemViewModel: URL {
        guard let items = self.viewModel?.items else { fatalError("not ready") }
        let leftIndex = items.index(before: self.currentCenterItemIndex)
        return leftIndex < 0 ? items.last! : items[leftIndex]
    }
    private var centerItemViewModel: URL {
        guard let items = self.viewModel?.items else { fatalError("not ready") }
        return items[self.currentCenterItemIndex]
    }
    private var rightItemViewModel: URL {
        guard let items = self.viewModel?.items else { fatalError("not ready") }
        let rightIndex = items.index(after: self.currentCenterItemIndex)
        return rightIndex >= items.count ? items.first! : items[rightIndex]
    }

leftItemViewModel, centerItemViewModel, rightItemViewModel- atas dasar currentCenterItemIndexkonten yang relevan kembali untuk setiap tampilan. force unwrapdan di fatalsini kita menggunakannya karena jumlah elemen ≥ 3 (jika diinginkan, Anda dapat menambahkan tanda centang pada metode set).

Tambahkan metode yang akan dipanggil jika perlu untuk mengubah konten tampilan:

    func nextItem() {
        self.currentCenterItemIndex += 1
        if self.viewModel?.items.count == self.currentCenterItemIndex {
            self.currentCenterItemIndex = 0
        }
        self.updateViews()
    }

    func prevItem() {
        self.currentCenterItemIndex -= 1
        if self.currentCenterItemIndex == -1 {
            self.currentCenterItemIndex = self.viewModel?.items.indices.last ?? 0
        }
        self.updateViews()
    }

    private func updateViews() {
        self.leftItemView.setImage(with: self.leftItemViewModel)
        self.centerItemView.setImage(with: self.centerItemViewModel)
        self.rightItemView.setImage(with: self.rightItemViewModel)
    }

Ubah metode yang digunakan secara eksternal untuk mengekspos konten:

    func set(viewModel: BannersViewModel) {
        self.viewModel = viewModel
        self.updateViews()
        self.scrollView.contentOffset.x = self.centerItemView.frame.minX - Constants.horizontalItemOffsetFromSuperView
    }

Dan kami akan menelepon nextItem, dan prevItemdalam metode delegasi ketika mengubah contentOffset:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        .......

        if (self.rightItemView.frame.maxX - gap) < currentRightOffset {
            scrollView.contentOffset.x -= self.centerItemView.frame.width + spacing
            self.nextItem()
        } else if (self.leftItemView.frame.minX + gap) > scrollView.contentOffset.x {
            scrollView.contentOffset.x += self.centerItemView.frame.width + spacing
            self.prevItem()
        }
    }

Mari kita tingkatkan jumlah tautan input ke gambar 5 (untuk kenyamanan ada tiga):


Langkah terakhir


Tetap membuat kustom UIViewalih-alih gambar sederhana. Ini akan menjadi judul, subtitle dan gambar.

Perpanjang ViewModel:

struct BannersViewModel {
    let items: [Item]

    struct Item {
        let title: String
        let subtitle: String
        let imageUrl: URL
    }
}

Dan tulis implementasi spanduk:

extension BannersView {
    final class ItemView: UIView {
        private let titleLabel = UILabel()
        private let subtitleLabel = UILabel()
        private let imageView = UIImageView()

        init() {
            super.init(frame: .zero)
            self.setup()
        }

        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        private func setup() {
            self.addSubview(self.imageView)
            self.addSubview(self.titleLabel)
            self.addSubview(self.subtitleLabel)

            self.imageView.contentMode = .scaleAspectFill

            self.layer.masksToBounds = true
            self.layer.cornerRadius = 8.0
        }

        func set(viewModel: BannersViewModel.Item) {
            self.titleLabel.text = viewModel.title
            self.subtitleLabel.text = viewModel.subtitle
            self.imageView.setImage(with: viewModel.imageUrl)
        }

        override func layoutSubviews() {
            super.layoutSubviews()
            self.imageView.frame = self.bounds

            self.titleLabel.frame.origin = CGPoint(x: 16.0, y: 16.0)
            self.titleLabel.frame.size = CGSize(width: self.bounds.width - 32.0, height: 20.0)

            self.subtitleLabel.frame.origin = CGPoint(x: 16.0, y: self.titleLabel.frame.maxY + 4.0)
            self.subtitleLabel.frame.size = self.titleLabel.frame.size
        }
    }
}

Ganti UIImageViewdan ViewModeldi BannersView::


    .......

    private let leftItemView = ItemView()
    private let centerItemView = ItemView()
    private let rightItemView = ItemView()
    
    private var leftItemViewModel: BannersViewModel.Item { ... }
    private var centerItemViewModel: BannersViewModel.Item { ... }
    private var rightItemViewModel: BannersViewModel.Item { ... }

    .......

    private func updateViews() {
        self.leftItemView.set(viewModel: self.leftItemViewModel)
        self.centerItemView.set(viewModel: self.centerItemViewModel)
        self.rightItemView.set(viewModel: self.rightItemViewModel)
    }

    .......

Hasil:


temuan


Membuat gulungan perulangan tanpa akhir dengan spanduk ternyata menjadi tugas yang menarik. Saya yakin bahwa setiap orang akan dapat menarik kesimpulan mereka sendiri atau mengambil ide dari keputusan kami untuk mengelola dengan hanya tiga yang dapat digunakan kembali UIView.

Sekali lagi, kami yakin bahwa solusi yang muncul pertama kali dan solusi yang dapat Anda temukan di Internet tidak selalu optimal. Pada awalnya, kami takut bahwa mengganti konten selama pengguliran akan menyebabkan masalah, tetapi semuanya berjalan lancar. Jangan takut untuk mencoba pendekatan Anda jika Anda pikir ini benar. Pikirkan dengan kepalamu sendiri :).

All Articles