HorizontalList с помощью SwiftUI

от автора

Вступление.

SwiftUI — это современный UI framework, который позволяет разработчикам быстро и легко создавать собственные приложения на всех платформах Apple.
Используя простой, понятный декларативный стиль, разработчики могут создавать потрясающие пользовательские интерфейсы с плавной анимацией. SwiftUI экономит время разработчиков, предоставляя огромное количество готовых решений, включая Interface Layout, Dark Mode, Accessibility, интернационализацию и многое другое. Приложения SwiftUI работают нативно и невероятно быстро. А поскольку SwiftUI — это один и тот же API, встроенный в iOS, iPadOS, macOS, watchOS и tvOS, разработчики могут быстрее и проще создавать отличные нативные приложения для всех платформ Apple.

Звучит amazing, не правда ли?

Введение.

SwiftUI был анонсирован на WWDC2019 и за последний год было написано множество статей, посвященных этому фреймворку. Поэтому в данной статье мы не будем заострять внимание на таких вещах, как

а сразу перейдем к практике и сделаем достаточно стандартную в повседневной жизни задачу — создание горизонтального списка.

Будет очень много кода и мало комментариев, впрочем, все как мы любим.

Глава 1. Что нам стоит горизонтальный ScrollView построить.

Горизонтальный список можно создать достаточно просто. Для этого необходимо поместить HStack в ScrollView и заполнить HStack нашими элементами:

var body: some View {     ScrollView(.horizontal) {         HStack {             ForEach(0...9, id: \.self) { index in                 SomeAmazingView(atIndex: index)             }         }     } }

Для практической наглядности создадим View, которая будет отображать список из 100 карточек. Каждая карточка будет отображать случайно сгенерированный смайлик и индекс самой карточки.

struct ContentView: View {      struct Constants {         static var itemsCount = 100     }      // MARK: - State         @State var items: [String] = []      // MARK: - Initialization     init() {         items = generateData()     }      // MARK: - View     var body: some View {         ScrollView(.horizontal) {             HStack {                 ForEach(0..<items.count, id: \.self) { index in                     CardView(index: index, title: self.items[index])                         .frame(width: 150, height: 200)                         .padding(10)                 }             }         }     }      // MARK: - Private Helpers     private func generateData() -> [String] {         var data: [String] = []         for _ in 0..<Constants.itemsCount {             data.append(String.randomEmoji())         }          return data     } }

Запускаем и вуаля, как и обещала Apple — все нативно и невероятно быстро.

enter image description here


Но есть одна проблема — если количество данных увеличится, то мы столкнемся с проблемой.

    ...     struct Constants {         static var itemsCount = 1000         ...     }     ...

Запускаем и …

enter image description here

Глава 2. Если хочешь сделать что-то хорошо, сделай это сам.

Для решения этой проблемы в UIKit мы бы использовали UICollectionView. Но, к сожалению, не всё, что было возможно при использовании UIKit, имеет аналог в SwiftUI.

Конечно, можно было бы использовать UICollectionView напрямую в SwiftUI. Как это сделать можно прочитать здесь. Но это уже не SwiftUI и определенно не наш путь.

На данный момент единственная структура в SwiftUI, которая загружает и отображает данные только по необходимости (on demand) это List.

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) public struct List<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View { ... }  @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) extension List {     ...     /// Creates a List that computes its rows on demand from an underlying     /// collection of identified data.     @available(watchOS, unavailable)     public init<Data, RowContent>(_ data: Data, selection: Binding<Set<SelectionValue>>?, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Content == ForEach<Data, Data.Element.ID, HStack<RowContent>>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable     }     ... }

Возьмем это решение от Apple и создадим схожее API для нашей структуры HorizontalList:

public struct HorizontalList<Content, Data> : View where Content : View, Data: RandomAccessCollection, Data.Element: Hashable {      // MARK: - Properties     private let data: [Data.Element]     private let itemContent: (Data.Element) -> Content      // MARK: - Initialization     public init(_ data: Data, @ViewBuilder itemContent: @escaping (Data.Element) -> Content) {         self.itemContent = itemContent          if let range = data as? Range<Int> {             self.data = Array(range.lowerBound..<range.upperBound) as! [Data.Element]         } else if let closedRange = data as? ClosedRange<Int> {             self.data = Array(closedRange.lowerBound..<closedRange.upperBound) as! [Data.Element]         } else if let array = data as? [Data.Element] {             self.data = array         } else {             fatalError("Unsupported data type.")         }     }      // MARK: - View     public var body: some View {         ZStack {             if !self.data.isEmpty {                 ForEach(0..<self.data.count, id: \.self) { index in                     self.makeView(atIndex: index)                 }             }         }     }      // MARK: - Private Helpers     private func makeView(atIndex index: Int) -> some View {         let item = data[index]         let content = itemContent(item)          return content     } }

Обновим наш пример с карточками и будем использовать собственное решение:

var body: some View {     HorizontalList(0..<items.count) { index in         CardView(index: index, title: self.items[index])             .frame(width: Constants.itemSize.width, height: Constants.itemSize.height)             .padding(10)     } }

Запускаем и (через какое-то время..) видим, что карточки успешно загрузились и отобразились:

enter image description here

Глава 2. Это особая, Layout магия.

Большую часть времени SwiftUI будет сам делать свою магию по расположению элементов и жизнь будет великолепной. Однако бывают случаи, когда нам требуется больше контроля для расположения наших собственных Views. Для этого у нас есть несколько инструментов. Одним из них является GeometryReader, его мы и будем использовать для получения информации о размерах и позициях элементов внутри него.

GeometryReader - A container view that defines its content as a function of its own size and coordinate space.

Вместо тысячи слов про GeometryReader, я бы посоветовал один раз перейти по ссылке и прочитать очень хорошую статью по теме.

Помимо расположения элементов в нашем HorizontalList нам необходимо знать размеры этих элементов. Естественно, мы могли бы задать статический размер, но это не то, как работает List и соответственно не то, как будем работать мы.

В SwiftUI есть механизм, который позволяет добавлять некоторые атрибуты к View. Эти атрибуты называются "Preferences". При изменении этих атрибутов мы будем получать callback.

Первым делом нам необходимо создать структуру данных для атрибута. В ней мы будем хранить индекс элемента и его Rect. Структура должна поддерживать протокол Equatable.

struct ViewRectPreferenceData: Equatable {     let index: Int     let rect: CGRect }

Следующим шагом создадим сам аттрибут, поддерживающий протокол PreferenceKey и содержащий в себе массив значений ViewRectPreferenceData.

struct ViewRectPreferenceKey: PreferenceKey {     typealias Value = [ViewRectPreferenceData]      static var defaultValue: [ViewRectPreferenceData] = []      static func reduce(value: inout [ViewRectPreferenceData], nextValue: () -> [ViewRectPreferenceData]) {         value.append(contentsOf: nextValue())     } }

Так как нам нужно будет знать размеры элементов, а сделать это можно только с помощью GeometryReader, мы создадим специальную View, которая будет добавлена как child к нашим элементам и, как результат, иметь GeometryReader с размерами нашего элемента.

struct PreferenceSetterView: View {     let index: Int     let coordinateSpaceName: String      var body: some View {         GeometryReader { geometry in             Rectangle()                 .fill(Color.clear)                 .preference(key: ViewRectPreferenceKey.self,                             value: [ViewRectPreferenceData(index: self.index, rect: geometry.frame(in: .named(self.coordinateSpaceName)))])         }     } }

Добавим созданный PreferenceSetterView к нашим элементам, как background:

private func makeView(atIndex index: Int) -> some View {     ...     return content             .background(PreferenceSetterView(index: index, coordinateSpaceName: Constants.coordinateSpaceName)) }

При изменении Preference вызывается метод onPreferenceChange, в нем то мы и получим размеры отображенных на экране элементов и сохраним их в наш массив. Так как у нас горизонтальный список, также мы будем высчитывать отступ элементов согласно отступу и размеру предыдущего элемента.

...  struct  Constants {     static  var  coordinateSpaceName: String {         return  "HorizontalListCoordinateSpaceName"     } }  @State private var rects: [Int: CGRect] = [:]  ...  public var body: some View {     GeometryReader { geometry in         ZStack {             ...          }          .onPreferenceChange(ViewRectPreferenceKey.self) { preferences in                 for preference in preferences {                     var rect = preference.rect                     if let prevRect = self.rects[preference.index - 1] {                         rect = CGRect(x: prevRect.maxX, y: rect.minY, width: rect.width, height: rect.height)                     }                      self.rects[preference.index] = rect           }           .coordinateSpace(name: Constants.coordinateSpaceName)         } }

Про Preferences eсть хорошая серия статей из 3-х частей:
Часть 1
Часть 2
Часть 3

Глава 3. Ты видишь только то, что тебе показывают.

Теперь, когда мы имеем размеры элементов и размеры экрана, мы легко можем посчитать какие элементы должны быть видимыми. Ниже реализован метод updateVisibleIndices и места откуда он будет вызываться:

@State  private  var  visibleIndices: ClosedRange<Int> = 0...0 ... public var body: some View {         GeometryReader { geometry in             ZStack {                 if !self.data.isEmpty {                     ForEach(self.model.visibleIndices, id: \.self) { index in                         self.makeView(atIndex: index)                      }                  }             }             .onAppear() {                 self.updateVisibleIndices(geometry: geometry)             }             .onPreferenceChange(ViewRectPreferenceKey.self) { preferences in                  ...                  self.updateVisibleIndices(geometry: geometry)              }         } }  ...  private func updateVisibleIndices(geometry: GeometryProxy) {     let bounds = geometry.frame(in: .named(Constants.coordinateSpaceName))     let visibleFrame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height)      var frameIndices: [Int] = []     for (index, rect) in rects {         if rect.intersects(visibleFrame) {             frameIndices.append(index)         }     }      frameIndices.sort()      let firstIndex = frameIndices.first ?? 0     var lastIndex = frameIndices.last ?? 0      if rects[lastIndex]?.maxX ?? 0 < visibleFrame.maxX, lastIndex < data.count - 1 {         lastIndex += 1     }      visibleIndices = firstIndex...lastIndex }

(пытливый критический взгляд может заметить, что количество visibleIndices не может быть меньше 1 и правильней было бы иметь это значение опциональным, но для простоты оставим как есть)

Протестируем, используя метод onAppear(), который вызывается при появлении элемента на экране:

CardView(index: index, title: self.items[index])     .onAppear() {         print("Appeared index: \(index)")     }

Appeared index: 0 Appeared index: 1 Appeared index: 2

Отлично, как видно из лога после запуска появились на экран только 3 элемента.

Глава 4. Тянем-потянем.

Следующим шагом развития нашего компонента будет поддержка Drag Gestures для скроллинга данных. Так как изменение позиции скролла влияет на отображаемые элементы, переменные offset и dragOffset будут State переменными.

@State private var offset: CGFloat = 0 @State private var dragOffset: CGFloat = 0  var contentOffset: CGFloat {     return offset + dragOffset }

Добавим к нашему компоненту DragGesture и его обработчики:

public var body: some View {     GeometryReader { geometry in         ZStack {             ...         }         .gesture(              DragGesture()                 .onChanged({ value in                      // Scroll by dragging                      self.dragOffset = -value.translation.width                      self.updateVisibleIndices(geometry: geometry)                  })                  .onEnded({ value in                       self.offset = self.offset + self.dragOffset                       self.dragOffset = 0                       self.updateVisibleIndices(geometry: geometry)                 }))     } }

В результате у нас появится вычисляемый contentOffset, который мы будем применять для калькуляции видимого фрейма и позиций элементов:

private func makeView(atIndex index: Int) -> some View {     ...     return content               .offset(x: itemRect.minX - contentOffset) }  private func updateVisibleIndices(geometry: GeometryProxy) {     ...     let visibleFrame = CGRect(x: contentOffset, y: 0, width: bounds.width, height: bounds.height)     ... }

Запускаем приложение:

enter image description here

Appeared index 0 Appeared index 1 Appeared index 2 Appeared index 3 Appeared index 4 Appeared index 5 Appeared index 6 Appeared index 7 Appeared index 8 Appeared index 9 Appeared index 10

Вот мы и реализовали основную логику для горизонтального скролла с большим количеством данных и загрузкой их по необходимости.

Глава 5. Крутите барабан.

Текущая реализация скролла элементов с помощью drag gesture не учитывает velocity. Настало время улучшить логику скролла и исправить этот пробел. Для того, чтобы учесть скорость прокрутки нам необходима анимация.

Любая анимация строится на изменении значений за какой-то период времени. Для того чтобы фиксировать периоды времени нам необходим таймер:

@State private var animationTimer = Timer.publish (every: 1/60, on: .current, in: .common).autoconnect()

Теперь когда у нас есть таймер, надо знать, что анимировать. Список получается довольно простой: startPosition, endPosition и scrollDuration. Единственное, так как нам не надо перегружать View при изменении какого-либо из этих значений, мы их упакуем в модель класса:

class HorizontalListScrollAnimator {     var isAnimationFinished: Bool = true      private var startPosition: CGFloat = 0     private var endPosition: CGFloat = 0     private var scrollDuration: Double = 0     private var startTime: TimeInterval = 0      func start(from start: CGFloat, to end: CGFloat, duration: Double = 1.0) {         startPosition = start         endPosition = end         scrollDuration = duration         isAnimationFinished = false         startTime = CACurrentMediaTime()     }      func stop() {         startPosition = 0         endPosition = 0         scrollDuration = 0         isAnimationFinished = true         startTime = 0     }      func nextStep() -> CGFloat {         let currentTime = CACurrentMediaTime()          let time = TimeInterval(min(1.0, (currentTime - startTime) / scrollDuration))          if time >= 1.0 {             isAnimationFinished = true             return endPosition         }          let delta = easeOut(time: time)         let scrollOffset = startPosition + (endPosition - startPosition) * CGFloat(delta)          return scrollOffset     }      private func easeOut(time: TimeInterval) -> TimeInterval {         return 1 - pow((1 - time), 4)     } }

И завершающим шагом интегрируем модель анимации с таймером в наше View:

private var scrollAnimator = HorizontalListScrollAnimator() public var body: some View {         GeometryReader { geometry in             ...         }         .gesture(             DragGesture()                 ...                 .onEnded({ value in                     let predictedWidth = value.predictedEndTranslation.width * 0.75                     if abs(predictedWidth) - abs(self.dragOffset) > geometry.size.width / 2 {                         // Scroll with animation to predicted offset                     self.dragOffset = 0                    self.scrollAnimator.start(from: self.offset, to: (self.offset - predictedWidth), duration: 2)                         self.animationTimer = Timer.publish (every: 1/60, on: .current, in:.common).autoconnect()                     } else {                         // Save dragging offset                         self.offset = self.offset + self.dragOffset                          self.dragOffset = 0                       self.updateVisibleIndices(geometry: geometry)          .gesture(              TapGesture()                  .onEnded({ _ in                       // Stop scroll animation on tap                             self.scrollAnimator.stop()                       self.animationTimer.upstream.connect().cancel()                     }))             }))             .onReceive(self.animationTimer) { _ in              if self.scrollAnimator.isAnimationFinished {                  // We don't need it when we start off                  self.animationTimer.upstream.connect().cancel()                  return              }               self.offset = self.scrollAnimator.nextStep()                  self.updateVisibleIndices(geometry: geometry)             }         }     }

Глава 6. Граница на замке.

В нашем рабочем компоненте есть одна проблема — можно спокойно проскроллить туда где нет данных. Поэтому следует добавить логику, которая будет проверять не вышли ли мы за границы. Реализуем метод safeOffset, возвращающий безопасные значения для отступа.

func safeOffset(x: CGFloat) -> CGFloat {     return x.clamped(to: 0...(maxOffset ?? CGFloat.greatestFiniteMagnitude)) }

У CGFloat нет метода clamped, но его можно легко добавить с помощью расширения:

extension Comparable {     func clamped(to limits: ClosedRange<Self>) -> Self {         return min(max(self, limits.lowerBound), limits.upperBound)     } }

Ниже представлен полный код с получением возможного максимального отступа и использованием метода safeOffset:

...  @State private var maxOffset: CGFloat?  var contentOffset: CGFloat {     return safeOffset(x: offset + dragOffset) }  ...  public var body: some View {         GeometryReader { geometry in             ...         }         .gesture(             DragGesture()                 ...                 .onEnded({ value in                     ...                     self.offset = self.safeOffset(x: self.offset + self.dragOffset)                      ...                  }))                  ...                  .onPreferenceChange(ViewRectPreferenceKey.self) { preferences in                       // Update subviews rects                       for preference in preferences {                           ...                           // Update max valid offset if needed                           if self.maxOffset == nil, let lastRect = self.rects[self.data.count - 1] {                               self.maxOffset = max(0, lastRect.maxX - geometry.frame(in: .global).width)                           }                       }                       ...                    }                    .onReceive(self.animationTimer) { _ in                         ....                         self.offset = self.scrollAnimator.nextStep()                          // Check if out of bounds                         let safeOffset = self.safeOffset(x: self.offset)                         if self.offset != safeOffset {                         self.offset = safeOffset                         self.dragOffset = 0                         ...                     } }

Глава 7. Кэш.

Для увеличения производительности, чтобы не создавать каждый раз элементы, создадим простой кэш, который будет чиститься в случае нехватки памяти:

class HorizontalListModel<Content> where Content : View {     var cachedContent: [Int: Content] = [:]      init() {         NotificationCenter.default.addObserver(self,                                                selector: #selector(clearCacheData),                                                name: UIApplication.didReceiveMemoryWarningNotification,                                                object: nil)     }      @objc func clearCacheData() {         cachedContent.removeAll()     } } 

... private let model = HorizontalListModel<Content>() ...      private func makeView(atIndex index: Int) -> some View {         ...         var content = model.cachedContent[index]         if content == nil {             content = itemContent(item)             model.cachedContent[index] = content         }          return content                 ...     }

enter image description here

Послесловие.

Готовый компонент вы можете найти по адресу: https://github.com/DistilleryTech/HorizontalList
Предыдущая наша статья на тему SwiftUI доступна здесь: https://habr.com/ru/post/501790/

Статья написана моим коллегой Денисом Шалагиным и опубликована для сообщества по его просьбе.

Всем счастливого WWDC2020!

ссылка на оригинал статьи https://habr.com/ru/post/507544/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *