يواجه كل مطور لمنصات الجوال باستمرار مهمة لا يمكن حلها بطريقة واحدة. هناك دائمًا عدة طرق ، بعضها سريع وبعضها معقد ، ولكل منها مزاياه وعيوبه.لا يمكن تنفيذ التمرير اللانهائي / الدوري في 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
:بعد الإعداد الأساسي ، يمكننا القيام بالتخطيط والموضع 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)
، يتم استدعاؤها عندما تتغير contentOffset
y 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
. على سبيل المثال ، إذا بقي rightItemView
100.0 نقطة ليتم تمريرها للعرض الكامل ، فإننا ننتقل contentOffset
للخلف ، بعرض بانر واحد ، مع مراعاة المسافة بين اللافتات (التباعد) ، بحيث تكون centerItemView
في مكانها rightItemView
. وبالمثل ، نحن نفعل ذلك leftItemView
: نحن نحسب النقطة ، بعد تقاطعها الذي سنقوم بتغييره contentOffset
.أضف طريقة func set(imageURLs: [URL])
لفضح البيانات للعرض في الخارج. هناك ننقل جزءًا من الرمز من setup
.وسنضيف أيضًا خطًا عندما تقوم بتعيين المحتوى يتم centerItemView
توسيطه على الفور. horizontalItemOffsetFromSuperView
استخدمناها بالفعل layoutSubviews
، لذلك وضعناها في الثوابت ونستخدمها مرة أخرى.func set(imageURLs: [URL]) {
let imageViews = [self.leftItemView, self.centerItemView, self.rightItemView]
imageViews.enumerated().forEach { key, view in
view.setImage(with: imageURLs[key])
}
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 {
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
.مرة أخرى ، كنا مقتنعين بأن الحلول التي تتبادر إلى الذهن أولاً والحلول التي يمكنك العثور عليها على الإنترنت ليست دائمًا مثالية. في البداية ، كنا نخشى أن يؤدي استبدال المحتوى أثناء التمرير إلى مشكلة ، ولكن كل شيء يعمل بسلاسة. لا تخف من تجربة نهجك إذا كنت تعتقد أن هذا صحيح. فكر برأسك :).