التمرير اللانهائي مع لافتات ، أو كيفية القيام بثلاث طرق عرض


يواجه كل مطور لمنصات الجوال باستمرار مهمة لا يمكن حلها بطريقة واحدة. هناك دائمًا عدة طرق ، بعضها سريع وبعضها معقد ، ولكل منها مزاياه وعيوبه.

لا يمكن تنفيذ التمرير اللانهائي / الدوري في iOS باستخدام أدوات قياسية ، تحتاج إلى الانتقال إلى حيل مختلفة. في هذه المقالة سأخبرك بأي خيارات لحل المشكلة تكمن على السطح والخيار الذي قمنا بتطبيقه في النهاية.

مهمة


كان من الضروري عمل تمرير دوري لا نهاية له مع عناصر في شكل صورة ، عنوان وعنوان فرعي معدة. الإدخال: يتم وضع مسافة بادئة للصورة المركزية من الشاشة بمقدار 16.0 نقطة. على جانبي الصورة المركزية ، تبرز "الأذنين". والمسافة بين اللافتات هي 8.0 نقاط.


ندرس ما تم القيام به من قبل الزملاء



دودو - اللافتات ليست دورية ، البانر المركزي دائمًا مجاور للحافة اليسرى على مسافة معينة.

Auto.ru - لافتات ليست دورية ، إذا قمت بالتمرير ، فعندها يتم قلب اللافتات لفترة طويلة جدًا.

الأوزون - لافتات دورية ، ولكن لا يمكن التحكم فيها باللمس: بمجرد تحديد اتجاه التمرير ، لا يمكن إيقاف الصورة.

Wildberries - اللافتات ليست دورية ، يحدث التمركز ، إتمام طويل للرسوم المتحركة للتمرير.

الرغبات النهائية:

  • يجب توسيط اللافتات.
  • يجب أن ينتهي التمرير دون انتظار طويل للرسوم المتحركة.
  • إدارة اللافتات: يجب أن تكون هناك إمكانية للتحكم في حركة التمرير بإصبعك.

خيارات التنفيذ


عندما تنشأ مهمة جديدة لم يتم حلها بعد ، يجدر النظر في الحلول والنهج القائمة.

UICollectionView


بالوكالة في الجبين ، يمكنك الوصول إلى خيار الإنشاء UICollectionView. نجعل عدد العناصر Int.maxوأثناء التهيئة نظهر الوسط ، وعند استدعاء الطريقة في dataSource- func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. سنقوم بإرجاع العنصر المقابل ، بافتراض أن العنصر صفر هو هذا Int.max / 2. مثل هذا الوحش مع مجموعة من الميزات ، مثل UICollectionView، غير مناسب للاستخدام في مهمتنا البسيطة.

UIScrollView و (n + 2) UIView


لا يزال هناك خيار يتم فيه إنشاء UIScrollView ، ويتم وضع جميع اللافتات على الإطلاق ، ويتم إضافته إلى البداية والنهاية بواسطة البانر. عندما ننتهي حتى النهاية ، نقوم بتغيير الإزاحة بشكل غير محسوس للمستخدم والعودة إلى العنصر الأول. وعند التمرير للخلف ، نقوم بالعكس. ونتيجة لذلك ، مع وجود عدد كبير من العناصر ، سيتم إنشاء مجموعة من المشاهدات دون إعادة استخدامها.


مصدر

بطريقته الخاصة


قررنا جعل UIScrollView + ثلاثة UIView. سيتم إعادة استخدام هذه UIView. في وقت التمرير ، سنعود contentOffsetإلى البانر المركزي ونستبدل محتوى UIView الثلاثة. ثم يجب أن تحصل على مكون خفيف يغلق هذه المهمة.

ومع ذلك ، هناك قلق من أن انتحال المحتوى أثناء التمرير سيكون ملحوظًا للمستخدم. نتعلم عن هذا أثناء التنفيذ.

التنفيذ


تحضير UIScrollView وثلاثة UIImageView


خلق وريث UIView، ضع UIScrollViewثلاثة عليه 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)
    }
}

أضف تنفيذ الطريقة مع الإعداد scrollView:

  • decelerationRate- تشير هذه المعلمة إلى مدى سرعة إبطاء حركة التمرير. في حالتنا ، هذا هو الأفضل .fast.
  • showsHorizontalScrollIndicator - هذه المعلمة مسؤولة عن عرض شريط التمرير الأفقي:

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

بعد الإعداد الأساسي ، يمكننا القيام بالتخطيط والموضع 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)
}

أضف إلى UIImageViewالصور التي سنقوم بسحبها من موقع مولد الصور https://placeholder.com :

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

نتيجة الخطوات التحضيرية الأولى:


توسيط الصور عند التمرير


للتحكم في التمرير سنستخدم UIScrollViewDelegate. قمنا setupبتعيين المفوض للطريقة UIScrollView، وقمنا أيضًا بتعيين contentInsetالصور الأولى والأخيرة لوضع مسافات بادئة على الجانبين.

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

نخلق extensionلدينا BannersViewواحدة من الأساليب. func scrollViewWillEndDraggingيتم استدعاء أسلوب المفوض عندما يتوقف المستخدم عن التمرير. في هذه الطريقة ، نحن مهتمون targetContentOffset- هذا هو المتغير المسؤول عن إزاحة التمرير النهائية (النقطة التي يتوقف عندها التمرير).


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- هذه هي المسافة التي نفترض أن وجهة النظر فيها مركزية. إذا تم عرض ثلث عرض الصورة البرتقالية على الشاشة ، فسنقوم بتعيين الإزاحة النهائية بحيث تكون الصورة البرتقالية في المنتصف.


targetRightOffsetX - ستساعد هذه النقطة على تحديد ما إذا كانت الرؤية الصحيحة هي المركزية.


نتيجة تطبيق هذه الطريقة:


إدارة الإزاحة أثناء التمرير


الآن ، أثناء التمرير ، سنغير contentOffset، ونعود إلى وسط الشاشة المنظر المركزي. سيسمح هذا للمستخدم بإنشاء وهم التمرير اللامتناهي دون أن يلاحظها أحد.

إضافة طريقة مفوض func scrollViewDidScroll(_ scrollView: UIScrollView)، يتم استدعاؤها عندما تتغير contentOffsety 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- هذه هي المسافة التي سنحدد من خلالها الحاجة إلى النزوح contentOffset. نحسب النقطة لـ rightItemView: ، self.rightItemView.frame.maxX — gapبعد أن نتحول إلى التقاطع contentOffset. على سبيل المثال ، إذا بقي rightItemView100.0 نقطة ليتم تمريرها للعرض الكامل ، فإننا ننتقل contentOffsetللخلف ، بعرض بانر واحد ، مع مراعاة المسافة بين اللافتات (التباعد) ، بحيث تكون centerItemViewفي مكانها rightItemView. وبالمثل ، نحن نفعل ذلك leftItemView: نحن نحسب النقطة ، بعد تقاطعها الذي سنقوم بتغييره contentOffset.


أضف طريقة func set(imageURLs: [URL])لفضح البيانات للعرض في الخارج. هناك ننقل جزءًا من الرمز من setup.

وسنضيف أيضًا خطًا عندما تقوم بتعيين المحتوى يتم centerItemViewتوسيطه على الفور. horizontalItemOffsetFromSuperViewاستخدمناها بالفعل layoutSubviews، لذلك وضعناها في الثوابت ونستخدمها مرة أخرى.

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
}

سندعو هذه الطريقة في الخارج UIViewController.viewDidAppear. أو يمكنك نقل المحاذاة الأولى إلى layoutSubviews، ولكن تحقق من أن ذلك سيتم فقط عند تغيير إطار العرض بالكامل. لتوضيح العمل ، سنستخدم الطريقة الأولى:


لذلك ... مع التمرير الحاد ، انكسر التمركز.


والحقيقة هي أنه مع التمرير القوي يتم تجاهلها targetContentOffset. دعنا نزيد contentInset، بعد ذلك كل شيء يعمل بشكل صحيح. سيتم توسيط العرض المركزي دائمًا.

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


نستبدل المحتوى


المهمة هي contentOffsetاستبدال محتوى العرض عند الإزاحة في نفس الوقت. عند التمرير إلى اليمين ، ستصبح الصورة اليمنى مركزية ، وسيصبح المركز يسارًا ، وسيكون اليسار يمينًا. 1 - 2 - 3 | 2 - 3 - 1.

للراحة ، قم بإنشاء نموذج عرض:

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

للتحقق من العنصر الموجود الآن في المركز ، أضف متغيرًا إلى BannersViewومتغيرات بمحتوى لكل طريقة عرض:

    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- على أساس currentCenterItemIndexإرجاع المحتوى ذي الصلة لكل طريقة عرض. force unwrapو fatalهنا نستخدم ذلك لعدد من العناصر غير ≥ 3 (إذا رغبت في ذلك، يمكنك إضافة الاختيار إلى طريقة set).

أضف الطرق التي سيتم استدعاؤها إذا لزم الأمر لتغيير محتوى المشاهدات:

    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)
    }

غيّر الطريقة المستخدمة خارجيًا لكشف المحتوى:

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

وسندعو nextItem، وفي prevItemالطريقة المندوب عند تغيير 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()
        }
    }

دعنا نزيد عدد روابط الإدخال للصور إلى 5 (للملاءمة كان هناك ثلاثة):


الخطوات النهائية


يبقى إجراء التخصيص UIViewبدلاً من الصورة البسيطة. سيكون هذا العنوان والعنوان الفرعي والصورة.

تمديد ViewModel:

struct BannersViewModel {
    let items: [Item]

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

وكتابة تنفيذ لافتة:

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
        }
    }
}

استبدال UIImageViewو ViewModelفي 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)
    }

    .......

نتيجة:


الموجودات


اتضح أن عمل حلقة متكررة لا نهاية لها مع لافتات مهمة مثيرة للاهتمام. أنا متأكد من أن الجميع سيكون قادرًا على استخلاص استنتاجاتهم الخاصة أو استخلاص أي أفكار من قرارنا بالتعامل مع ثلاثة فقط قابلة لإعادة الاستخدام UIView.

مرة أخرى ، كنا مقتنعين بأن الحلول التي تتبادر إلى الذهن أولاً والحلول التي يمكنك العثور عليها على الإنترنت ليست دائمًا مثالية. في البداية ، كنا نخشى أن يؤدي استبدال المحتوى أثناء التمرير إلى مشكلة ، ولكن كل شيء يعمل بسلاسة. لا تخف من تجربة نهجك إذا كنت تعتقد أن هذا صحيح. فكر برأسك :).

All Articles