فهم UICollectionViewLayout مع تطبيق الصور

مرحبا يا هبر! اسمي Nikita ، أعمل على أجهزة SDK المحمولة في ABBYY ، كما أتعامل مع مكون واجهة المستخدم لمسح المستندات متعددة الصفحات وعرضها بسهولة على الهاتف الذكي. يقلل هذا المكون من الوقت اللازم لتطوير التطبيقات القائمة على تقنية ABBYY Mobile Capture ويتكون من عدة أجزاء. أولاً ، كاميرا لمسح المستندات ضوئيًا ؛ ثانياً ، شاشة محرر مع نتائج الالتقاط (أي ، التقاط الصور تلقائيًا) وشاشة لتصحيح حدود المستند.

يكفي للمطور الاتصال بطريقتين - والآن في تطبيقه ، تتوفر كاميرا بالفعل تقوم بمسح المستندات تلقائيًا. ولكن ، بالإضافة إلى الكاميرات التي تم تكوينها ، تحتاج إلى تزويد العملاء بوصول سهل إلى نتائج الفحص ، أي الصور التي التقطت تلقائيا. وإذا قام العميل بمسح العقد أو الميثاق ، فيمكن أن يكون هناك الكثير من هذه الصور.

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



كمراجع ، غالباً ما ألفت الانتباه إلى تطبيقات نظام Apple. عندما تنظر بعناية إلى الرسوم المتحركة وحلول الواجهة الأخرى لتطبيقاتها ، فإنك تبدأ في الإعجاب بموقفها المتنبه من تفاهات متعددة. سننظر الآن في تطبيق الصور (iOS 12) كمرجع. سألفت انتباهك إلى الميزات المحددة لهذا التطبيق ، ومن ثم سنحاول تنفيذها.

سنغطي معظم UICollectionViewFlowLayout التخصيص UICollectionViewFlowLayout ، ونرى كيف يتم تطبيق التقنيات الشائعة مثل المنظر UICollectionViewFlowLayout ، ومناقشة المشكلات المرتبطة بالرسوم المتحركة المخصصة عند إدراج الخلايا وحذفها.

مراجعة الميزات


لإضافة تفاصيل ، سوف أصف الأشياء الصغيرة المحددة التي يسرني في تطبيق الصور ، ثم سأطبقها بالترتيب المناسب.

  1. تأثير المنظر في مجموعة كبيرة
  2. تتركز عناصر مجموعة صغيرة.
  3. الحجم الديناميكي للعناصر في مجموعة صغيرة
  4. يعتمد منطق وضع عناصر خلية صغيرة ليس فقط على contentOffset ، ولكن أيضًا على تفاعلات المستخدم
  5. الرسوم المتحركة المخصصة للتحرك والحذف
  6. لا يتم فقد فهرس الخلية "النشطة" عند تغيير الاتجاه

1. المنظر


ما هو المنظر؟
التمرير المنهجي هو تقنية في رسومات الكمبيوتر حيث تتحرك صور الخلفية عبر الكاميرا ببطء أكثر من الصور الأمامية ، مما يخلق وهمًا بالعمق في مشهد ثنائي الأبعاد ويزيد من شعور الانغماس في التجربة الافتراضية.
يمكنك ملاحظة أنه عند التمرير ، يتحرك إطار الخلية بشكل أسرع من الصورة الموجودة به.


لنبدأ! إنشاء فئة فرعية من الخلية ، ووضع UIImageView في ذلك.

 class PreviewCollectionViewCell: UICollectionViewCell { private(set) var imageView = UIImageView()​ override init(frame: CGRect) { super.init(frame: frame) addSubview(imageView) clipsToBounds = true imageView.snp.makeConstraints { $0.edges.equalToSuperview() } } }  class PreviewCollectionViewCell: UICollectionViewCell { private(set) var imageView = UIImageView()​ override init(frame: CGRect) { super.init(frame: frame) addSubview(imageView) clipsToBounds = true imageView.snp.makeConstraints { $0.edges.equalToSuperview() } } } 

أنت الآن بحاجة إلى فهم كيفية تحويل imageView ، وخلق تأثير المنظر. للقيام بذلك ، تحتاج إلى إعادة تعريف سلوك الخلايا أثناء التمرير. أبل:
تجنب فئة فرعية UICollectionView . عرض المجموعة له مظهر بسيط أو لا يتمتع بأي مظهر. بدلاً من ذلك ، يسحب كل طرق العرض الخاصة به من كائن مصدر البيانات الخاص بك وجميع المعلومات المتعلقة بالتخطيط من كائن التخطيط. إذا كنت تحاول وضع العناصر في ثلاثة أبعاد ، فإن الطريقة المناسبة للقيام بذلك هي تنفيذ تخطيط مخصص يحدد التحويل ثلاثي الأبعاد لكل خلية وعرضه بشكل مناسب.
حسنًا ، لنقم بإنشاء كائن التخطيط الخاص بنا. تحتوي UICollectionView على مجموعة خاصية UICollectionView ، والتي تتعلم منها معلومات حول موضع الخلايا. UICollectionViewFlowLayout هو تطبيق UICollectionViewLayout المجردة ، وهي خاصية collectionViewLayout .
UICollectionViewLayout يقوم شخص ما UICollectionViewLayout إلى فئة فرعية ويوفر المحتوى المناسب. UICollectionViewFlowLayout هي فئة ملموسة من UICollectionViewLayout التي تم تنفيذ جميع أعضائها الأربعة ، في الطريقة التي سيتم ترتيب الخلايا بطريقة الشبكة.
قم بإنشاء فئة فرعية من UICollectionViewFlowLayout وتجاوز layoutAttributesForElements(in:) . تُرجع الطريقة صفيف UICollectionViewLayoutAttributes ، والذي يوفر معلومات حول كيفية عرض خلية معينة.

تطلب المجموعة سمات في كل مرة يتغير فيها contentOffset ، وكذلك عندما يكون التخطيط غير صالح. بالإضافة إلى ذلك ، سننشئ سمات مخصصة عن طريق إضافة خاصية parallaxValue ، والتي تحدد مقدار تأخر إطار الصورة من إطار الخلية. بالنسبة NSCopiyng الفرعية للسمة ، يجب عليك تجاوز NSCopiyng لها. أبل:
إذا كنت من الفئات الفرعية وقمت بتطبيق أي سمات تخطيط مخصصة ، فيجب عليك أيضًا تجاوز isEqual: الطريقة الموروثة لمقارنة قيم خصائصك. في نظام التشغيل iOS 7 والإصدارات الأحدث ، لا يطبق عرض المجموعة سمات التخطيط إذا لم تتغير تلك السمات. يحدد ما إذا كانت السمات قد تغيرت من خلال مقارنة كائنات السمات القديمة والجديدة باستخدام isEqual: method. نظرًا لأن التطبيق الافتراضي لهذه الطريقة يتحقق فقط من الخصائص الحالية لهذه الفئة ، يجب تطبيق الإصدار الخاص بك من الطريقة لمقارنة أي خصائص إضافية. إذا كانت جميع خصائصك المخصصة متساوية ، فاتصل بـ super واسترجع القيمة الناتجة في نهاية التنفيذ.
كيفية معرفة parallaxValue ؟ دعنا نحسب مقدار ما تحتاجه لتحريك مركز الخلية بحيث يكون في الوسط. إذا كانت هذه المسافة أكبر من عرض الخلية ، ثم مطرقة عليها. بخلاف ذلك ، قسّم هذه المسافة على عرض الخلية . كلما اقتربت هذه المسافة من الصفر ، كان تأثير المنظر أضعف.

 class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } }  class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes { var parallaxValue: CGFloat? }​ class PreviewLayout: UICollectionViewFlowLayout { var offsetBetweenCells: CGFloat = 44override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }​ override class var layoutAttributesClass: AnyClass { return ParallaxLayoutAttributes.self }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return super.layoutAttributesForElements(in: rect)? .compactMap { $0.copy() as? ParallaxLayoutAttributes } .compactMap(prepareAttributes) }​ private func prepareAttributes(attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes { guard let collectionView = self.collectionView else { return attributes }​ let width = itemSize.width let centerX = width / 2 let distanceToCenter = attributes.center.x - collectionView.contentOffset.x let relativeDistanceToCenter = (distanceToCenter - centerX) / width​ if abs(relativeDistanceToCenter) >= 1 { attributes.parallaxValue = nil attributes.transform = .identity } else { attributes.parallaxValue = relativeDistanceToCenter attributes.transform = CGAffineTransform(translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0) } return attributes } } 


صورة

عندما تتلقى المجموعة السمات الضرورية ، تطبقها الخلايا. يمكن تجاوز هذا السلوك في فئة فرعية من الخلية. دعنا imageView على القيمة اعتمادا على parallaxValue . ومع ذلك ، لكي يعمل تحول الصور مع contentMode == .aspectFit بشكل صحيح ، فهذا لا يكفي ، لأن إطار الصورة لا يتزامن مع إطار imageView ، والذي يتم من خلاله اقتصاص المحتوى عندما يكون clipsToBounds == true . ضع قناعًا يطابق حجم الصورة مع contentMode المناسب وسوف نقوم بتحديثه إذا لزم الأمر. الآن كل شيء يعمل!

 extension PreviewCollectionViewCell {​ override func layoutSubviews() {​ super.layoutSubviews() guard let imageSize = imageView.image?.size else { return } let imageRect = AVMakeRect(aspectRatio: imageSize, insideRect: bounds)​ let path = UIBezierPath(rect: imageRect) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath layer.mask = shapeLayer } override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {​ guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else { return super.apply(layoutAttributes) } let parallaxValue = attrs.parallaxValue ?? 0 let transition = -(bounds.width * 0.3 * parallaxValue) imageView.transform = CGAffineTransform(translationX: transition, y: .zero) } }  extension PreviewCollectionViewCell {​ override func layoutSubviews() {​ super.layoutSubviews() guard let imageSize = imageView.image?.size else { return } let imageRect = AVMakeRect(aspectRatio: imageSize, insideRect: bounds)​ let path = UIBezierPath(rect: imageRect) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath layer.mask = shapeLayer } override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {​ guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else { return super.apply(layoutAttributes) } let parallaxValue = attrs.parallaxValue ?? 0 let transition = -(bounds.width * 0.3 * parallaxValue) imageView.transform = CGAffineTransform(translationX: transition, y: .zero) } }  extension PreviewCollectionViewCell {​ override func layoutSubviews() {​ super.layoutSubviews() guard let imageSize = imageView.image?.size else { return } let imageRect = AVMakeRect(aspectRatio: imageSize, insideRect: bounds)​ let path = UIBezierPath(rect: imageRect) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath layer.mask = shapeLayer } override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {​ guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else { return super.apply(layoutAttributes) } let parallaxValue = attrs.parallaxValue ?? 0 let transition = -(bounds.width * 0.3 * parallaxValue) imageView.transform = CGAffineTransform(translationX: transition, y: .zero) } }  extension PreviewCollectionViewCell {​ override func layoutSubviews() {​ super.layoutSubviews() guard let imageSize = imageView.image?.size else { return } let imageRect = AVMakeRect(aspectRatio: imageSize, insideRect: bounds)​ let path = UIBezierPath(rect: imageRect) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath layer.mask = shapeLayer } override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {​ guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else { return super.apply(layoutAttributes) } let parallaxValue = attrs.parallaxValue ?? 0 let transition = -(bounds.width * 0.3 * parallaxValue) imageView.transform = CGAffineTransform(translationX: transition, y: .zero) } }  extension PreviewCollectionViewCell {​ override func layoutSubviews() {​ super.layoutSubviews() guard let imageSize = imageView.image?.size else { return } let imageRect = AVMakeRect(aspectRatio: imageSize, insideRect: bounds)​ let path = UIBezierPath(rect: imageRect) let shapeLayer = CAShapeLayer() shapeLayer.path = path.cgPath layer.mask = shapeLayer } override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {​ guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else { return super.apply(layoutAttributes) } let parallaxValue = attrs.parallaxValue ?? 0 let transition = -(bounds.width * 0.3 * parallaxValue) imageView.transform = CGAffineTransform(translationX: transition, y: .zero) } } 


2. تتركز عناصر مجموعة صغيرة




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

 extension ThumbnailFlowLayout {​ var farInset: CGFloat { guard let collection = collectionView else { return .zero } return (collection.bounds.width - itemSize.width) / 2 } var insets: UIEdgeInsets { UIEdgeInsets(top: .zero, left: farInset, bottom: .zero, right: farInset) }​ override func prepare() { collectionView?.contentInset = insets super.prepare() } }  extension ThumbnailFlowLayout {​ var farInset: CGFloat { guard let collection = collectionView else { return .zero } return (collection.bounds.width - itemSize.width) / 2 } var insets: UIEdgeInsets { UIEdgeInsets(top: .zero, left: farInset, bottom: .zero, right: farInset) }​ override func prepare() { collectionView?.contentInset = insets super.prepare() } }  extension ThumbnailFlowLayout {​ var farInset: CGFloat { guard let collection = collectionView else { return .zero } return (collection.bounds.width - itemSize.width) / 2 } var insets: UIEdgeInsets { UIEdgeInsets(top: .zero, left: farInset, bottom: .zero, right: farInset) }​ override func prepare() { collectionView?.contentInset = insets super.prepare() } } 


صورة


مزيد من المعلومات حول توسيط: عندما تنتهي المجموعة من التمرير ، يطلب التخطيط إيقاف contentOffset مجموعة contentOffset . للقيام بذلك ، تجاوز targetContentOffset(forProposedContentOffset:withScrollingVelocity:) . أبل:
إذا كنت تريد أن ينتقل سلوك التمرير إلى حدود محددة ، فيمكنك تجاوز هذه الطريقة واستخدامها لتغيير النقطة التي تتوقف عندها. على سبيل المثال ، يمكنك استخدام هذه الطريقة لإيقاف التمرير دائمًا على الحدود بين العناصر ، بدلاً من التوقف في منتصف العنصر.
لجعل كل شيء جميلًا ، سنتوقف دائمًا في وسط أقرب خلية. إن حساب مركز الخلية الأقرب مهمة تافهة إلى حد ما ، ولكن عليك أن تكون حذراً وأن تأخذ في الاعتبار contentInset .

 override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { guard let collection = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) } let cellWithSpacing = itemSize.width + config.distanceBetween let relative = (proposedContentOffset.x + collection.contentInset.left) / cellWithSpacing let leftIndex = max(0, floor(relative)) let rightIndex = min(ceil(relative), CGFloat(itemsCount)) let leftCenter = leftIndex * cellWithSpacing - collection.contentInset.left let rightCenter = rightIndex * cellWithSpacing - collection.contentInset.leftif abs(leftCenter - proposedContentOffset.x) < abs(rightCenter - proposedContentOffset.x) { return CGPoint(x: leftCenter, y: proposedContentOffset.y) } else { return CGPoint(x: rightCenter, y: proposedContentOffset.y) } }  override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { guard let collection = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) } let cellWithSpacing = itemSize.width + config.distanceBetween let relative = (proposedContentOffset.x + collection.contentInset.left) / cellWithSpacing let leftIndex = max(0, floor(relative)) let rightIndex = min(ceil(relative), CGFloat(itemsCount)) let leftCenter = leftIndex * cellWithSpacing - collection.contentInset.left let rightCenter = rightIndex * cellWithSpacing - collection.contentInset.leftif abs(leftCenter - proposedContentOffset.x) < abs(rightCenter - proposedContentOffset.x) { return CGPoint(x: leftCenter, y: proposedContentOffset.y) } else { return CGPoint(x: rightCenter, y: proposedContentOffset.y) } } 




3. الحجم الديناميكي لعناصر مجموعة صغيرة


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


يمكنك استخدام نفس الأسلوب كما في حالة المنظر. UICollectionViewFlowLayout بإنشاء UICollectionViewFlowLayout مخصص UICollectionViewFlowLayout صغيرة وإعادة تعريف prepareAttributes(attributes: نظرًا لأنه سيتم زيادة تعقيد منطق تخطيط المجموعة الصغيرة ، سنقوم بإنشاء كيان منفصل لتخزين وحساب هندسة الخلية.

 struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } }  struct Cell { let indexPath: IndexPathlet dims: Dimensions let state: Statefunc updated(new state: State) -> Cell { return Cell(indexPath: indexPath, dims: dims, state: state) } }​ extension Cell { struct Dimensions {​ let defaultSize: CGSize let aspectRatio: CGFloat let inset: CGFloat let insetAsExpanded: CGFloat }​ struct State {​ let expanding: CGFloatstatic var `default`: State { State(expanding: .zero) } } } 

يحتوي UICollectionViewFlowLayout على خاصية collectionViewContentSize التي تحدد حجم المساحة التي يمكن تمريرها. حتى لا نعقد حياتنا ، فلنتركها ثابتة ومستقلة عن حجم الخلية المركزية. للحصول على الشكل الهندسي الصحيح لكل خلية ، تحتاج إلى معرفة الجانب aspectRatio للصورة وبُعد مركز الخلية من contentOffset . كلما اقتربت الخلية ، كلما اقترب size.width / size.height aspectRatio . aspectRatio إلى aspectRatio . عند تغيير حجم خلية معينة ، انقل الخلايا المتبقية (إلى اليمين واليسار منها) باستخدام affineTransform . اتضح أنه لحساب هندسة خلية معينة ، تحتاج إلى معرفة سمات الجيران (مرئية).

 extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } }  extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } }  extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } }  extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } }  extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } }  extension Cell {​ func attributes(from layout: ThumbnailLayout, with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {​ let attributes = layout.layoutAttributesForItem(at: indexPath)​ attributes?.size = size attributes?.center = center​ let translate = sideCells.reduce(0) { (current, cell) -> CGFloat in if indexPath < cell.indexPath { return current - cell.additionalWidth / 2 } if indexPath > cell.indexPath { return current + cell.additionalWidth / 2 } return current } attributes?.transform = CGAffineTransform(translationX: translate, y: .zero)​ return attributes } var additionalWidth: CGFloat { (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding } var size: CGSize { CGSize(width: dims.defaultSize.width + additionalWidth, height: dims.defaultSize.height) } var center: CGPoint { CGPoint(x: CGFloat(indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2, y: dims.defaultSize.height / 2) } } 

يعتبر state.expanding هو نفسه تقريبا مثل parallaxValue .

 func cell(for index: IndexPath, offsetX: CGFloat) -> Cell {​ let cell = Cell( indexPath: index, dims: Cell.Dimensions( defaultSize: itemSize, aspectRatio: dataSource(index.row), inset: config.distanceBetween, insetAsExpanded: config.distanceBetweenFocused), state: .default)​ guard let attribute = cell.attributes(from: self, with: []) else { return cell }​ let cellOffset = attribute.center.x - itemSize.width / 2 let widthWithOffset = itemSize.width + config.distanceBetween if abs(cellOffset - offsetX) < widthWithOffset { let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset return cell.updated(by: .expand(expanding)) } return cell }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .compactMap { $0.attributes(from: self, with: cells) } }  func cell(for index: IndexPath, offsetX: CGFloat) -> Cell {​ let cell = Cell( indexPath: index, dims: Cell.Dimensions( defaultSize: itemSize, aspectRatio: dataSource(index.row), inset: config.distanceBetween, insetAsExpanded: config.distanceBetweenFocused), state: .default)​ guard let attribute = cell.attributes(from: self, with: []) else { return cell }​ let cellOffset = attribute.center.x - itemSize.width / 2 let widthWithOffset = itemSize.width + config.distanceBetween if abs(cellOffset - offsetX) < widthWithOffset { let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset return cell.updated(by: .expand(expanding)) } return cell }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .compactMap { $0.attributes(from: self, with: cells) } }  func cell(for index: IndexPath, offsetX: CGFloat) -> Cell {​ let cell = Cell( indexPath: index, dims: Cell.Dimensions( defaultSize: itemSize, aspectRatio: dataSource(index.row), inset: config.distanceBetween, insetAsExpanded: config.distanceBetweenFocused), state: .default)​ guard let attribute = cell.attributes(from: self, with: []) else { return cell }​ let cellOffset = attribute.center.x - itemSize.width / 2 let widthWithOffset = itemSize.width + config.distanceBetween if abs(cellOffset - offsetX) < widthWithOffset { let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset return cell.updated(by: .expand(expanding)) } return cell }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .compactMap { $0.attributes(from: self, with: cells) } }  func cell(for index: IndexPath, offsetX: CGFloat) -> Cell {​ let cell = Cell( indexPath: index, dims: Cell.Dimensions( defaultSize: itemSize, aspectRatio: dataSource(index.row), inset: config.distanceBetween, insetAsExpanded: config.distanceBetweenFocused), state: .default)​ guard let attribute = cell.attributes(from: self, with: []) else { return cell }​ let cellOffset = attribute.center.x - itemSize.width / 2 let widthWithOffset = itemSize.width + config.distanceBetween if abs(cellOffset - offsetX) < widthWithOffset { let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset return cell.updated(by: .expand(expanding)) } return cell }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .compactMap { $0.attributes(from: self, with: cells) } }  func cell(for index: IndexPath, offsetX: CGFloat) -> Cell {​ let cell = Cell( indexPath: index, dims: Cell.Dimensions( defaultSize: itemSize, aspectRatio: dataSource(index.row), inset: config.distanceBetween, insetAsExpanded: config.distanceBetweenFocused), state: .default)​ guard let attribute = cell.attributes(from: self, with: []) else { return cell }​ let cellOffset = attribute.center.x - itemSize.width / 2 let widthWithOffset = itemSize.width + config.distanceBetween if abs(cellOffset - offsetX) < widthWithOffset { let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset return cell.updated(by: .expand(expanding)) } return cell }​ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .compactMap { $0.attributes(from: self, with: cells) } } 


4. يعتمد منطق وضع عناصر خلية صغيرة ليس فقط على contentOffset ، ولكن أيضًا على تفاعلات المستخدم


عندما ينتقل المستخدم عبر مجموعة صغيرة ، تكون كل الخلايا بنفس الحجم. عند التمرير مجموعة كبيرة ، هذا ليس كذلك. ( انظر الصور 3 و 5 ). دعنا نكتب رسما كاريكاتوريا سيقوم بتحديث خصائص تخطيط ThumbnailLayout . سيقوم رسّام الرسوم المتحركة بتخزين DisplayLink بنفسه واستدعاء الكتلة 60 مرة في الثانية ، مما يتيح الوصول إلى التقدم الحالي. من السهل easing functions المختلفة easing functions المتحرك. يمكن الاطلاع على التطبيق على جيثب على الرابط في نهاية المنشور.

دعنا ندخل خاصية ThumbnailLayout في ThumbnailLayout ، حيث يتم مضاعفة expanding كل Cell . اتضح أن aspectRatio لصورة معينة سيؤثر على حجمها إذا أصبحت مركزة. مع expandingRate == 0 ستكون جميع الخلايا بنفس الحجم. في بداية التمرير لمجموعة صغيرة ، سنشغل رسامًا متحركًا يضبط معدل expandingRate إلى 0 ، وفي نهاية التمرير ، بالعكس ، إلى 1. في الواقع ، عند تحديث المخطط ، سيتغير حجم الخلية المركزية والخلايا الجانبية. لا contentOffset مع contentOffset !

 class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Typefunc run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } }  class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Typefunc run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } }  class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Typefunc run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } }  class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Typefunc run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } }  class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Typefunc run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } }  class ScrollAnimation: NSObject {​ enum `Type` { case begin case end }​ let type: Typefunc run(completion: @escaping () -> Void) { let toValue: CGFloat = self.type == .begin ? 0 : 1 let currentExpanding = thumbnails.config.expandingRate let duration = TimeInterval(0.15 * abs(currentExpanding - toValue))​ let animator = Animator(onProgress: { current, _ in let rate = currentExpanding + (toValue - currentExpanding) * current self.thumbnails.config.expandingRate = rate self.thumbnails.invalidateLayout() }, easing: .easeInOut)​ animator.animate(duration: duration) { _ in completion() } } } 


 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { handle(event: .beginScrolling) // call ScrollAnimation.run(type: .begin) } }​ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if scrollView == thumbnails.collectionView && !decelerate { thumbnailEndScrolling() } }​ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { thumbnailEndScrolling() } }​ func thumbnailEndScrolling() { handle(event: .endScrolling) // call ScrollAnimation.run(type: .end) }  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { handle(event: .beginScrolling) // call ScrollAnimation.run(type: .begin) } }​ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if scrollView == thumbnails.collectionView && !decelerate { thumbnailEndScrolling() } }​ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { thumbnailEndScrolling() } }​ func thumbnailEndScrolling() { handle(event: .endScrolling) // call ScrollAnimation.run(type: .end) }  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { handle(event: .beginScrolling) // call ScrollAnimation.run(type: .begin) } }​ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if scrollView == thumbnails.collectionView && !decelerate { thumbnailEndScrolling() } }​ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { thumbnailEndScrolling() } }​ func thumbnailEndScrolling() { handle(event: .endScrolling) // call ScrollAnimation.run(type: .end) }  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { handle(event: .beginScrolling) // call ScrollAnimation.run(type: .begin) } }​ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if scrollView == thumbnails.collectionView && !decelerate { thumbnailEndScrolling() } }​ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if scrollView == thumbnails.collectionView { thumbnailEndScrolling() } }​ func thumbnailEndScrolling() { handle(event: .endScrolling) // call ScrollAnimation.run(type: .end) } 


5. الرسوم المتحركة المخصصة للتحرك وحذف


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

تحديث المحتوى في UICollectionViewFlowLayout يعمل على النحو التالي. بعد حذف / إضافة خلية ، تبدأ طريقة prepare(forCollectionViewUpdates:) ، مع إعطاء صفيف من UICollectionViewUpdateItem ، والذي يخبرنا عن الخلايا التي تم تحديث / حذف / إضافتها. بعد ذلك ، سيتصل التصميم بمجموعة من الطرق

 finalLayoutAttributesForDisappearingItem(at:) initialLayoutAttributesForAppearingDecorationElement(ofKind:at:) 

وأصدقائهم للزينة / وجهات النظر التكميلية. عند تلقي سمات البيانات المحدّثة ، يتم استدعاء finalizeCollectionViewUpdates . أبل:
طريقة العرض المجموعة تدعو هذه الطريقة كخطوة أخيرة قبل المتابعة لتحريك أي تغييرات في مكانها. يتم استدعاء هذه الطريقة داخل كتلة الرسوم المتحركة المستخدمة لتنفيذ جميع الرسوم المتحركة الخاصة بالإدراج والحذف ونقلها حتى تتمكن من إنشاء رسوم متحركة إضافية باستخدام هذه الطريقة حسب الحاجة. خلاف ذلك ، يمكنك استخدامه لأداء أي مهام في اللحظة الأخيرة المرتبطة بإدارة معلومات حالة كائن التخطيط الخاص بك.
المشكلة هي أنه لا يمكننا تخصيص السمات إلا للخلية المحدثة ، ونحن بحاجة إلى تغييرها لجميع الخلايا ، وبطرق مختلفة. يجب أن تتغير الخلية المركزية الجديدة من aspectRatio ، ويجب أن تتغير aspectRatio الجانبية.


بعد فحص كيفية عمل الرسوم المتحركة الافتراضية لخلايا المجموعة أثناء الحذف / الإدراج ، أصبح من المعروف أن طبقات الطبقة في finalizeCollectionViewUpdates تحتوي على CABasicAnimation ، والتي يمكن تغييرها هناك إذا كنت ترغب في تخصيص الرسوم المتحركة للخلايا المتبقية. تزداد الأمور سوءًا عندما أظهرت السجلات أنه من بين performBatchUpdates prepare(forCollectionViewUpdates:) prepareAttributes(attributes:) prepare(forCollectionViewUpdates:) تسمى ، وقد يكون هناك بالفعل عدد خاطئ من الخلايا ، على الرغم من أن collectionViewUpdates لم تبدأ بعد ، من الصعب جدًا الحفاظ عليها وفهمها. ما الذي يمكن عمله حيال ذلك؟ يمكنك تعطيل هذه الرسوم المتحركة المدمجة!

 final override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) CATransaction.begin() CATransaction.setDisableActions(true) }​ final override func finalizeCollectionViewUpdates() { CATransaction.commit() }  final override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) CATransaction.begin() CATransaction.setDisableActions(true) }​ final override func finalizeCollectionViewUpdates() { CATransaction.commit() } 

مسلحًا برسومات الرسوم المتحركة المكتوبة بالفعل ، سنفعل جميع الرسوم المتحركة اللازمة عند طلب الحذف ، dataSource في تحديث dataSource في نهاية الرسوم المتحركة. وبالتالي ، سنعمل على تبسيط الرسوم المتحركة للمجموعة عند التحديث ، لأننا نتحكم عندما يتغير عدد الخلايا.

 func delete( at indexPath: IndexPath, dataSourceUpdate: @escaping () -> Void, completion: (() -> Void)?) {​ DeleteAnimation(thumbnails: thumbnails, preview: preview, index: indexPath).run { let previousCount = self.thumbnails.itemsCount if previousCount == indexPath.row + 1 { self.activeIndex = previousCount - 1 } dataSourceUpdate() self.thumbnails.collectionView?.deleteItems(at: [indexPath]) self.preview.collectionView?.deleteItems(at: [indexPath]) completion?() } }  func delete( at indexPath: IndexPath, dataSourceUpdate: @escaping () -> Void, completion: (() -> Void)?) {​ DeleteAnimation(thumbnails: thumbnails, preview: preview, index: indexPath).run { let previousCount = self.thumbnails.itemsCount if previousCount == indexPath.row + 1 { self.activeIndex = previousCount - 1 } dataSourceUpdate() self.thumbnails.collectionView?.deleteItems(at: [indexPath]) self.preview.collectionView?.deleteItems(at: [indexPath]) completion?() } } 

كيف ستعمل هذه الرسوم المتحركة؟ في ThumbnailLayout دعنا نخزن كتيبات اختيارية تقوم بتحديث هندسة خلايا معينة.

 class ThumbnailLayout {​ typealias CellUpdate = (Cell) -> Cell var updates: [IndexPath: CellUpdate] = [:] // ... override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {​ let cells = (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .map { cell -> Cell in if let update = self.config.updates[cell.indexPath] { return update(cell) } return cell } return cells.compactMap { $0.attributes(from: self, with: cells) } }  class ThumbnailLayout {​ typealias CellUpdate = (Cell) -> Cell var updates: [IndexPath: CellUpdate] = [:] // ... override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {​ let cells = (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .map { cell -> Cell in if let update = self.config.updates[cell.indexPath] { return update(cell) } return cell } return cells.compactMap { $0.attributes(from: self, with: cells) } }  class ThumbnailLayout {​ typealias CellUpdate = (Cell) -> Cell var updates: [IndexPath: CellUpdate] = [:] // ... override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {​ let cells = (0 ..< itemsCount) .map { IndexPath(row: $0, section: 0) } .map { cell(for: $0, offsetX: offsetWithoutInsets.x) } .map { cell -> Cell in if let update = self.config.updates[cell.indexPath] { return update(cell) } return cell } return cells.compactMap { $0.attributes(from: self, with: cells) } } 

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

 updates[index] = newUpdate(updates[index]) 

يعد رمز الحذف المتحرك مرهقًا إلى حد ما ؛ فهو موجود في ملف DeleteAnimation.swift في المستودع. يتم تنفيذ الرسوم المتحركة الخاصة بتبديل التركيز بين الخلايا بنفس الطريقة.



6. لا تضيع فهرس الخلية "النشطة" عند تغيير الاتجاه


scrollViewDidScroll(_ scrollView:) حتى لو كنت ببساطة تنبثق ببعض القيمة في contentOffset ، وكذلك عند تغيير الاتجاه. عند مزامنة تمرير مجموعتين ، قد تنشأ بعض المشاكل أثناء تحديثات التخطيط. تساعد الخدعة التالية: في تحديثات التخطيط ، يمكنك ضبط scrollView.delegate على nil .

 extension ScrollSynchronizer {​ private func bind() { preview.collectionView?.delegate = self thumbnails.collectionView?.delegate = self }​ private func unbind() { preview.collectionView?.delegate = nil thumbnails.collectionView?.delegate = nil } }  extension ScrollSynchronizer {​ private func bind() { preview.collectionView?.delegate = self thumbnails.collectionView?.delegate = self }​ private func unbind() { preview.collectionView?.delegate = nil thumbnails.collectionView?.delegate = nil } }  extension ScrollSynchronizer {​ private func bind() { preview.collectionView?.delegate = self thumbnails.collectionView?.delegate = self }​ private func unbind() { preview.collectionView?.delegate = nil thumbnails.collectionView?.delegate = nil } } 

عند تحديث أحجام الخلايا في وقت تغيير الاتجاه ، سيبدو كما يلي:

 extension PhotosViewController {​ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator)​ contentView.synchronizer.unbind() coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.contentView.synchronizer.bind() } } }  extension PhotosViewController {​ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator)​ contentView.synchronizer.unbind() coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.contentView.synchronizer.bind() } } }  extension PhotosViewController {​ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator)​ contentView.synchronizer.unbind() coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.contentView.synchronizer.bind() } } } 

لكي لا تفقد contentOffset المطلوب ، عند تغيير الاتجاه ، يمكنك تحديث targetIndexPath في scrollView.delegate . عند تغيير الاتجاه ، سيتم تعطيل التخطيط إذا shouldInvalidateLayout(forBoundsChange:) . عند تغيير bounds سيطلب التخطيط توضيح contentOffset ، لتوضيح ذلك ، ستحتاج إلى إعادة تعريف targetContentOffset(forProposedContentOffset:) . أبل:
أثناء تحديثات التخطيط ، أو عند الانتقال بين التخطيطات ، تستدعي طريقة العرض المجموعة هذه الطريقة لمنحك الفرصة لتغيير إزاحة المحتوى المقترح لاستخدامها في نهاية الرسوم المتحركة. يمكنك تجاوز هذه الطريقة إذا كانت الرسوم المتحركة أو الانتقال قد تتسبب في وضع العناصر بطريقة غير مناسبة للتصميم الخاص بك.

طريقة العرض مجموعة يستدعي هذا الأسلوب بعد استدعاء أساليب prepare() و collectionViewContentSize .


 override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { let targetOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset) guard let layoutHandler = layoutHandler else { return targetOffset } let offset = CGFloat(layoutHandler.targetIndex) / CGFloat(itemsCount) return CGPoint( x: collectionViewContentSize.width * offset - farInset, y: targetOffset.y) } 





شكرا للقراءة!

يمكن العثور على جميع الشفرات على github.com/YetAnotherRzmn/PhotosApp

Source: https://habr.com/ru/post/ar477734/


All Articles