Desplazamiento infinito con pancartas, o Cómo hacer con tres vistas


Cada desarrollador para plataformas móviles se enfrenta constantemente a una tarea que no se puede resolver de una sola manera. Siempre hay varias formas, algunas rápidas, otras complicadas, y cada una tiene sus propias ventajas y desventajas.

El desplazamiento sin fin / cíclico en iOS no se puede implementar usando herramientas estándar, debe ir a diferentes trucos. En este artículo, le diré qué opciones para resolver el problema se encuentran en la superficie y qué opción implementamos eventualmente.

Tarea


Era necesario hacer un desplazamiento cíclico sin fin con elementos en forma de una imagen preparada, título y subtítulo. Entrada: la imagen central se sangra de la pantalla en 16.0 puntos. A los lados de la imagen central, sobresalen las "orejas". Y la distancia entre las pancartas es de 8.0 puntos.


Estudiamos lo que han hecho los colegas.



Dodo: las pancartas no son cíclicas, la pancarta central siempre está adyacente al borde izquierdo a cierta distancia.

Auto.ru: las pancartas no son cíclicas; si desliza el dedo, las pancartas se vuelven durante mucho tiempo.

Ozon: las pancartas son cíclicas, pero no se pueden controlar con el tacto: una vez que se determina la dirección del deslizamiento, la imagen ya no se puede detener.

Arándanos silvestres: los banners no son cíclicos, se produce el centrado y se completa la animación de desplazamiento.

Deseos finales:

  • Las pancartas deben estar centradas.
  • El desplazamiento debería finalizar sin una larga espera para la animación.
  • Capacidad de administración de pancartas: debe haber la capacidad de controlar la animación de desplazamiento con el dedo.

Opciones de implementación


Cuando surge una nueva tarea que aún no se ha resuelto, vale la pena considerar las soluciones y enfoques existentes.

UICollectionView


Actuando en la frente, puede llegar a la opción de crear UICollectionView. Hacemos el número de elementos Int.maxy durante la inicialización mostramos el medio, y cuando llamamos al método en dataSource- func collectionView(UICollectionView, cellForItemAt: IndexPath) -> UICollectionViewCell. Devolveremos el elemento correspondiente, suponiendo que el elemento cero es este Int.max / 2. Tal monstruo con un montón de características, como UICollectionView, es inapropiado de usar para nuestra tarea simple.

UIScrollView y (n + 2) UIView


Todavía hay una opción en la que se crea UIScrollView, absolutamente todos los banners se colocan en él, y se agrega al principio y al final mediante el banner. Cuando lo terminamos hasta el final, cambiamos el desplazamiento de manera imperceptible para el usuario y volvemos al primer elemento. Y al desplazarse hacia atrás, hacemos lo contrario. Como resultado, con una gran cantidad de elementos, se crearán un montón de vistas sin reutilizarlas.


Fuente

Propia manera


Decidimos hacer UIScrollView + tres UIView. Estas UIView serán reutilizadas. En el momento del desplazamiento, volveremos contentOffsetal banner central y reemplazaremos el contenido de los tres UIView. Y luego debe obtener un componente ligero que cierre esta tarea.

Sin embargo, existe la preocupación de que la suplantación de contenido durante el desplazamiento sea notoria para el usuario. Aprendemos sobre esto durante la implementación.

Implementación


Preparando UIScrollView y Three UIImageView


Crea un heredero UIView, coloca UIScrollViewtres en él 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)
    }
}

Agregue la implementación del método con la configuración scrollView:

  • decelerationRate- Este parámetro indica qué tan rápido se ralentizará la animación de desplazamiento. En nuestro caso, es lo mejor .fast.
  • showsHorizontalScrollIndicator - este parámetro es responsable de mostrar la barra de desplazamiento horizontal:

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

Después de la configuración básica, podemos hacer el diseño y la ubicación 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)
}

Agregue a las UIImageViewimágenes que extraeremos del sitio del generador de imágenes https://placeholder.com :

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

El resultado de los primeros pasos preparatorios:


Centrar imágenes al desplazarse


Para controlar el desplazamiento usaremos UIScrollViewDelegate. setupConfiguramos el delegado para el método UIScrollView, y también configuramos contentInsetla primera y la última imagen para que tengan sangrías en los lados.

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

Creamos extensionpara nuestro BannersViewuno de los métodos. Se func scrollViewWillEndDraggingllama al método delegado cuando el usuario deja de desplazarse. En este método, nos interesa targetContentOffset: esta es la variable responsable del desplazamiento final del desplazamiento (el punto en el que se detiene el desplazamiento).


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- esta es la distancia a la que asumiremos que la vista es central. Si se muestra un tercio del ancho de la imagen naranja en la pantalla, estableceremos el desplazamiento final para que la imagen naranja esté en el centro.


targetRightOffsetX - Este punto ayudará a determinar si la vista correcta es central.


El resultado de la implementación de este método:


Administrar el desplazamiento mientras se desplaza


Ahora, justo durante el desplazamiento, cambiaremos contentOffset, volviendo al centro de la pantalla la vista central. Esto permitirá al usuario crear una ilusión de desplazamiento infinito inadvertido.

Agregue un método delegado func scrollViewDidScroll(_ scrollView: UIScrollView), se llama cuando contentOffsety cambia 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- esta es la distancia desde la cual determinaremos la necesidad de desplazamiento contentOffset. Calculamos el punto para rightItemView:, self.rightItemView.frame.maxX — gapdespués de la intersección de la cual cambiaremos contentOffset. Por ejemplo, si rightItemViewquedan 100.0 puntos para desplazarse a pantalla completa , entonces contentOffsetretrocedemos, por el ancho de una pancarta, teniendo en cuenta la distancia entre las pancartas (espaciado), para que esté centerItemViewen su lugar rightItemView. Del mismo modo, lo hacemos para leftItemView: calculamos el punto, después de la intersección de la cual cambiaremos contentOffset.


Agregue un método func set(imageURLs: [URL])para exponer datos para su visualización en exteriores. Allí transferimos parte del código de setup.

Y también agregaremos una línea para que cuando establezca el contenido se centerItemViewcentre inmediatamente. horizontalItemOffsetFromSuperViewya lo usamos layoutSubviews, así que lo ponemos en constantes y lo usamos nuevamente.

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
}

Llamaremos a este método de afuera hacia adentro UIViewController.viewDidAppear. O puede mover la primera alineación a layoutSubviews, pero verifique que esto se haga solo cuando cambie el marco de toda la vista. Para demostrar el trabajo, usaremos el primer método:


Entonces ... Con un pergamino afilado, el centrado se rompió.


El hecho es que con un desplazamiento fuerte se ignora targetContentOffset. Vamos a aumentar contentInset, después de eso todo funciona correctamente. La vista central siempre estará centrada.

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


Reemplazamos contenido


La tarea es contentOffsetreemplazar el contenido de la vista en el desplazamiento al mismo tiempo. Al desplazarse hacia la derecha, la imagen derecha se convertirá en central, el centro se convertirá en izquierda y la izquierda será derecha. 1 - 2 - 3 | 2 - 3 - 1.

Para mayor comodidad, cree un ViewModel:

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

Para verificar qué elemento está ahora en el centro, agregue una variable BannersViewy variables con contenido para cada una de las vistas:

    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- sobre la base de currentCenterItemIndexun contenido relevante de retorno para cada vista. force unwrapy fatalaquí lo usamos porque el número de elementos es ≥ 3 (si lo desea, puede agregar un cheque al método set).

Agregue métodos que se llamarán si es necesario para cambiar el contenido de las vistas:

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

Cambie el método utilizado externamente para exponer contenido:

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

Y llamaremos nextItem, y prevItemen el método al delegado al cambiar 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()
        }
    }

Aumentemos el número de enlaces de entrada a las imágenes a 5 (por conveniencia, hubo tres):


Pasos finales


Queda por hacer UIViewuna imagen personalizada en lugar de una simple. Este será el título, subtítulo e imagen.

Ampliar ViewModel:

struct BannersViewModel {
    let items: [Item]

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

Y escriba una implementación de banner:

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

Reemplazar UIImageViewy ViewModelen 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)
    }

    .......

Resultado:


recomendaciones


Hacer un desplazamiento en bucle sin fin con pancartas resultó ser una tarea interesante. Estoy seguro de que todos podrán sacar sus propias conclusiones o sacar cualquier idea de nuestra decisión de administrar con solo tres reutilizables UIView.

Una vez más, estábamos convencidos de que las soluciones que vienen a la mente primero y las soluciones que puede encontrar en Internet no siempre son óptimas. Al principio, temíamos que reemplazar el contenido durante el desplazamiento condujera a un problema, pero todo funcionó sin problemas. No tenga miedo de probar sus enfoques si cree que esto es correcto. Piensa con tu propia cabeza :).

All Articles