August 3, 2024

78. Пример кастомной коллекции

Для создания коллекций в SwiftUI есть как минимум LazyVStack и LazyHStack. В этой статье покажу как можно сделать свою горизонтальную коллекцию с кастомной логикой распределения чипсов по строкам.

Скриншот готовой коллекции

Постановка

Будем делать коллекцию с такими параметрами:

  1. Максимум 2 строки чипсов
  2. Если чипсов меньше 5, то располагаем их в одну строку
  3. Если чипсов больше 5, то располагаем их в 2 строки
  4. Если количество чипсов нечетное, то в первой строке их должно быть больше на 1, чем во второй
  5. Ожидается общее количество чипсов не больше 15
  6. При нажатии на чипс нужно вернуть в родительскую вьюху идентификатор нажатого чипса

Решение

Для начала нам понадобится модель чипсины, и сразу сделаем метод для удобного создания превью-заглушки с коллекцией чипсов:

/// Модель для чипсины в коллекции
struct ChipItem: Identifiable, Hashable {
  let id: Int
  let description: String
}

extension [ChipItem] {
  /// Создает массив с заданным количеством чипсов
  static func makeDemoList(count: Int) -> [ChipItem] {
    (0..<count).map { i in
      return .init(
        id: i + 1,
        description: "Тут чипс № \(i + 1)"
      )
    }
  }
}

Теперь сделаем модель для коллекции, которая будет заниматься распределением чипсов по строкам. Для реализации такого поведения будем использовать структуру и словарь:

/// Модель для коллекции чипсов, создает словарь
/// для отображения коллекции в одну или две строки
struct ChipCollectionModel {
  /// Итоговый словарь, где ключ - порядковый номер строки, а значение - чипсы для этой строки
  let itemsDict: [Int: [ChipItem]]
  /// Количество строк в коллекции
  var rows: Int { itemsDict.keys.count }
   
  init(items: [ChipItem]) {
    guard !items.isEmpty else {
      itemsDict = [:] // Если на вход не поступили элементы, словарь будет пустым
      return
    }
    if items.count < 5 {
      itemsDict = [1: items] // Если элементов меньше 5, будет 1 строка
    } else {
      let count = items.count
      let isEven = count % 2 == 0
      let midIndex = isEven ? count / 2 : (count + 1) / 2
      itemsDict = [
        1: Array(items[..<midIndex]),
        2: Array(items[midIndex...])
      ]
    }
  }
}

Осталось сверстать коллекцию:

struct ChipsCollectionView: View {
  private let model: ChipCollectionModel
  @Binding private var selection: Int?
   
  /// Инициализатор
  /// - Parameters:
  ///  - items: Список чипсов для коллекции
  ///  - selection: Идентификатор выбранного чипса
  init(items: [ChipItem], selection: Binding<Int?>) {
    self.model = .init(items: items)
    self._selection = selection
  }
   
  var body: some View {
    ScrollView(.horizontal, showsIndicators: false) {
      VStack(alignment: .leading, spacing: 4) {
        ForEach(0..<model.rows, id: \.self) { row in
          if let items = model.itemsDict[row + 1] {
            HStack(spacing: 4) {
              ForEach(items) { item in
                makeChipView(for: item)
                  .onTapGesture { selection = item.id }
              }
            }
          }
        }
      }
      .padding(.horizontal, 4)
    }
  }
   
  private func makeChipView(for item: ChipItem) -> some View {
    Text(item.description)
      .foregroundStyle(.primary)
      .padding(8)
      .background {
        RoundedRectangle(cornerRadius: 20)
          .fill(.secondary.opacity(0.3))
      }
  }
}

Скриншоты

4 чипса уместились в 1 строку
5 чипсов разделились на 2 строки, в первой больше на 1 чипс
15 чипсов разделились на 2 строки, как и 5

Бонус - тесты

Напишем unit-тесты на модель для коллекции:

import XCTest
@testable import DemoApp

final class ChipCollectionModelTests: XCTestCase {
   
  func testEmptyCollection() {
    let model = ChipCollectionModel(items: [])
    XCTAssertEqual(model.rows, 0)
    XCTAssertTrue(model.itemsDict.isEmpty)
  }
   
  func testSingleRowCollection() {
    let items = [
      ChipItem(id: 1, description: "Chips A"),
      ChipItem(id: 2, description: "Chips B"),
      ChipItem(id: 3, description: "Chips C"),
      ChipItem(id: 4, description: "Chips D")
    ]
     
    let model = ChipCollectionModel(items: items)
     
    XCTAssertEqual(model.rows, 1)
    XCTAssertEqual(model.itemsDict.count, 1)
    XCTAssertEqual(model.itemsDict[1]?.count, 4)
    XCTAssertEqual(model.itemsDict[1], items)
  }
   
  func testTwoRowCollectionEvenCount() {
    let items = [
      ChipItem(id: 1, description: "Chips A"),
      ChipItem(id: 2, description: "Chips B"),
      ChipItem(id: 3, description: "Chips C"),
      ChipItem(id: 4, description: "Chips D"),
      ChipItem(id: 5, description: "Chips E"),
      ChipItem(id: 6, description: "Chips F")
    ]
     
    let model = ChipCollectionModel(items: items)
     
    XCTAssertEqual(model.rows, 2)
    XCTAssertEqual(model.itemsDict.count, 2)
    XCTAssertEqual(model.itemsDict[1]?.count, 3)
    XCTAssertEqual(model.itemsDict[2]?.count, 3)
    XCTAssertEqual(model.itemsDict[1], Array(items[0...2]))
    XCTAssertEqual(model.itemsDict[2], Array(items[3...5]))
  }
   
  func testTwoRowCollectionOddCount() {
    let items = [
      ChipItem(id: 1, description: "Chips A"),
      ChipItem(id: 2, description: "Chips B"),
      ChipItem(id: 3, description: "Chips C"),
      ChipItem(id: 4, description: "Chips D"),
      ChipItem(id: 5, description: "Chips E")
    ]
     
    let model = ChipCollectionModel(items: items)
     
    XCTAssertEqual(model.rows, 2)
    XCTAssertEqual(model.itemsDict.count, 2)
    XCTAssertEqual(model.itemsDict[1]?.count, 3)
    XCTAssertEqual(model.itemsDict[2]?.count, 2)
    XCTAssertEqual(model.itemsDict[1], Array(items[0...2]))
    XCTAssertEqual(model.itemsDict[2], Array(items[3...4]))
  }
}

Заключение

Вот таким нехитрым способом можно сделать свою коллекцию в SwiftUI - на мой взгляд это намного удобнее, чем в UIKit 😁

Код для этой статьи можно посмотреть тут, другие статьи по разработке - тут, а про инвестиции - тут.