Вступление.
SwiftUI это современный UI framework, который позволяет
разработчикам быстро и легко создавать собственные приложения на
всех платформах Apple.
Используя простой, понятный декларативный стиль, разработчики могут
создавать потрясающие пользовательские интерфейсы с плавной
анимацией. SwiftUI экономит время разработчиков, предоставляя
огромное количество готовых решений, включая Interface Layout, Dark
Mode, Accessibility, интернационализацию и многое другое.
Приложения SwiftUI работают нативно и невероятно быстро. А
поскольку SwiftUI это один и тот же API, встроенный в iOS, iPadOS,
macOS, watchOS и tvOS, разработчики могут быстрее и проще создавать
отличные нативные приложения для всех платформ Apple.
Звучит amazing, не правда ли?
Введение.
SwiftUI был анонсирован на WWDC2019 и за последний год было написано множество статей, посвященных этому фреймворку. Поэтому в данной статье мы не будем заострять внимание на таких вещах, как
- почему View это структура, а не класс
- что такое @State
- что такое Function Builders и кто такой @ViewBuilder
а сразу перейдем к практике и сделаем достаточно стандартную в повседневной жизни задачу создание горизонтального списка.
Будет очень много кода и мало комментариев, впрочем, все как мы любим.
Глава 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 все нативно и невероятно быстро.
Но есть одна проблема если количество данных увеличится, то мы столкнемся с проблемой.
... struct Constants { static var itemsCount = 1000 ... } ...
Запускаем и ...
Глава 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) }}
Запускаем и (через какое-то время..) видим, что карточки успешно загрузились и отобразились:
Глава 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: 0Appeared index: 1Appeared index: 2
Отлично, как видно из лога после запуска появились на экран только 3 элемента.
Глава 4. Тянем-потянем.
Следующим шагом развития нашего компонента будет поддержка Drag Gestures для скроллинга данных. Так как изменение позиции скролла влияет на отображаемые элементы, переменные offset и dragOffset будут State переменными.
@State private var offset: CGFloat = 0@State private var dragOffset: CGFloat = 0var 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) ...}
Запускаем приложение:
Appeared index 0Appeared index 1Appeared index 2Appeared index 3Appeared index 4Appeared index 5Appeared index 6Appeared index 7Appeared index 8Appeared index 9Appeared 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 ... }
Послесловие.
Готовый компонент вы можете найти по адресу: https://github.com/DistilleryTech/HorizontalList
Предыдущая наша статья на тему SwiftUI доступна здесь:
http://personeltest.ru/aways/habr.com/ru/post/501790/
Статья написана моим коллегой Денисом Шалагиным и опубликована для сообщества по его просьбе.
Всем счастливого WWDC2020!