Skip to content

Instantly share code, notes, and snippets.

@shial4
Last active February 29, 2024 20:56
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save shial4/d797f3c3f51a87f7fa48fd144ed3972b to your computer and use it in GitHub Desktop.
Save shial4/d797f3c3f51a87f7fa48fd144ed3972b to your computer and use it in GitHub Desktop.
SwiftUI Dynamic Horizontal or vertical List - loads only visible elements. Efficiency for large collections. Dynamic item size.

SwiftUI DynamicList

SwiftUI DynamicList is a custom list view designed to handle a large number of elements efficiently, supporting both horizontal and vertical orientations. It loads only the visible elements, ensuring optimal performance when working with thousands of items.

This and more components can be found: SwiftUIComponents

Features

  • Efficiently handles a large number of elements in both horizontal and vertical orientations.
  • Loads only the visible elements, improving performance.
  • Supports dynamic item widths and heights.
  • Demonstrates view reuse for improved efficiency.
  • scrollOffset binding, allows programatically to manipulate offset

Usage

IMB_NpSo98

Clipped Content (Horizontal)

struct ClippedContentView: View {
    var body: some View {
        DynamicList(numberOfItems: 10000, itemLength: 80) { index in
            Text("\(index)")
        }
        .frame(width: 200, height: 60)
        .border(Color.blue, width: 1)
        .clipped()
    }
}

Clipped Content

Non-Clipped Content (Horizontal)

struct NonClippedContentView: View {
    var body: some View {
        DynamicList(numberOfItems: 10000, itemLength: 80) { index in
            Text("\(index)")
        }
        .frame(width: 200, height: 60)
        .border(Color.red, width: 1)
    }
}

Non-Clipped Content

Non-Clipped Content With Different Item Size (Horizontal)

struct NonClippedDifferentWidthContentView: View {
    var body: some View {
        DynamicList(itemLengths: [120, 80, 40, 90, 220, 70, 80]) { index in
            Text("\(index)")
        }
        .frame(width: 200, height: 60)
        .border(Color.red, width: 1)
    }
}

Clipped Content (Vertical)

struct ClippedVerticalContentView: View {
    var body: some View {
        DynamicList(numberOfItems: 10000, itemLength: 80)
            .orientation(.vertical)
            .frame(width: 60, height: 200)
            .border(Color.blue, width: 1)
            .clipped()
    }
}

Non-Clipped Content (Vertical)

struct NonClippedVerticalContentView: View {
    var body: some View {
        DynamicList(orientation: .vertical, numberOfItems: 10000, itemLength: 80)
            .frame(width: 60, height: 200)
            .border(Color.red, width: 1)
    }
}

Non-Clipped Content With Different Item Size (Vertical)

struct NonClippedDifferentWidthVerticalContentView: View {
    var body: some View {
        DynamicList(itemLengths: [120, 80, 40, 90, 220, 70, 80])
            .orientation(.vertical)
            .frame(width: 60, height: 200)
            .border(Color.red, width: 1)
    }
}

How It Works

The SwiftUI DynamicList efficiently manages a large number of elements by loading only the visible content. It calculates the start and end indexes of the visible items, ensuring that only those items are displayed. This approach significantly improves performance when dealing with a vast number of elements in both horizontal and vertical lists.

SwiftUI DynamicList also supports dynamic item widths and heights, allowing you to create lists with varying item sizes.

Installation

To use SwiftUI DynamicList in your project, simply copy and paste the DynamicList struct into your SwiftUI project. You can then use it as shown in the examples above.

License

SwiftUI DynamicList is available under the MIT license. See the LICENSE file for more information.

import SwiftUI
import Foundation
import PlaygroundSupport
public enum Orientation {
case horizontal
case vertical
}
public struct StackView<Content: View>: View {
private var orientation: Orientation
private let content: Content
init(orientation: Orientation, @ViewBuilder content: () -> Content) {
self.orientation = orientation
self.content = content()
}
public var body: some View {
if orientation == .horizontal {
HStack(spacing: 0) {
content
}
} else {
VStack(spacing: 0) {
content
}
}
}
}
public struct DynamicList<Content: View>: View {
@StateObject private var viewModel = ViewModel()
@Binding private var scrollOffset: Double
private let animation: Animation = .easeInOut(duration: 0.3)
private let length: Length
private let cellBuilder: (Int) -> Content
private var orientation: Orientation
public init(
scrollOffset: Binding<Double>,
orientation: Orientation = .horizontal,
numberOfItems: Int,
itemLength: Double,
viewForCell cellBuilder: @escaping (Int) -> Content
) {
self.length = Length(length: itemLength, numberOfItems: numberOfItems)
self.cellBuilder = cellBuilder
self.orientation = orientation
self._scrollOffset = scrollOffset
}
public init(
scrollOffset: Binding<Double>,
orientation: Orientation = .horizontal,
itemLengths: [Double],
viewForCell cellBuilder: @escaping (Int) -> Content
) {
self.length = Length(lengths: itemLengths)
self.cellBuilder = cellBuilder
self.orientation = orientation
self._scrollOffset = scrollOffset
}
public init(
orientation: Orientation = .horizontal,
numberOfItems: Int,
itemLength: Double,
viewForCell cellBuilder: @escaping (Int) -> Content
) {
self.init(
scrollOffset: .constant(0),
orientation: orientation,
numberOfItems: numberOfItems,
itemLength: itemLength,
viewForCell: cellBuilder
)
}
public init(
orientation: Orientation = .horizontal,
itemLengths: [Double],
viewForCell cellBuilder: @escaping (Int) -> Content
) {
self.init(
scrollOffset: .constant(0),
orientation: orientation,
itemLengths: itemLengths,
viewForCell: cellBuilder
)
}
public var body: some View {
GeometryReader { geometry in
listView(geometry.size)
.onChange(of: scrollOffset) { value in
guard scrollOffset != viewModel.scrollOffset else { return }
let scrollOffset: Double
switch orientation {
case .horizontal:
scrollOffset = max(min(0, value), geometry.size.width - length.total)
case .vertical:
scrollOffset = max(min(0, value), geometry.size.height - length.total)
}
viewModel.previousScrollOffset = scrollOffset
if self.scrollOffset != scrollOffset {
self.scrollOffset = scrollOffset
}
viewModel.isLeadingAnimation = viewModel.scrollOffset < scrollOffset
withAnimation(animation) {
viewModel.scrollOffset = scrollOffset
}
}
}
}
private var previousScrollOffset: Double {
viewModel.previousScrollOffset
}
private func transition(
index: Int,
start: (index: Int, width: Double),
endIndex: Int
) -> AnyTransition {
let isLeading: Bool
if viewModel.isLeadingAnimation == true {
isLeading = index <= (endIndex - start.index)
} else {
isLeading = index < (endIndex - start.index)
}
let x = orientation == .horizontal ? isLeading ? -length[start.index] : length[start.index] : 0
let y = orientation == .vertical ? isLeading ? -length[start.index] : length[start.index] : 0
return .asymmetric(
insertion: .offset(x: x, y: y),
removal: .opacity
)
}
private func dragGesture(screenDimension size: Double) -> some Gesture {
DragGesture()
.onChanged{ value in
let translation = orientation == .horizontal ? value.translation.width : value.translation.height
let scrollOffset = max(min(0, previousScrollOffset + translation), size - length.total)
viewModel.isLeadingAnimation = nil
viewModel.scrollOffset = scrollOffset
self.scrollOffset = scrollOffset
}
.onEnded { value in
withAnimation(animation) {
let translation = orientation == .horizontal ? value.predictedEndTranslation.width : value.predictedEndTranslation.height
let scrollOffset = max(min(0, previousScrollOffset + translation), size - length.total)
viewModel.scrollOffset = scrollOffset
self.scrollOffset = scrollOffset
viewModel.previousScrollOffset = viewModel.scrollOffset
}
}
}
private func index(for offset: Double) -> (index: Int, width: Double) {
var startIndex = 0
var accumulatedWidth: Double = 0
for (index, width) in length.enumerated() {
if accumulatedWidth < (offset - width) {
startIndex = index
} else {
break
}
accumulatedWidth += width
}
return (startIndex, accumulatedWidth)
}
private func endIndex(for start: (index: Int, width: Double), screenWidth: Double) -> Int {
var endIndex = start.index
var accumulatedWidth = start.width
while endIndex < length.count, accumulatedWidth <= (start.width + screenWidth) {
accumulatedWidth += length[endIndex]
endIndex += 1
}
if (endIndex + 1) < length.count {
endIndex += 1
}
return endIndex
}
private func listView(_ size: CGSize) -> some View {
let screenDimension: Double
let scrollOffset: Double
switch orientation {
case .horizontal:
screenDimension = size.width
scrollOffset = viewModel.scrollOffset
case .vertical:
screenDimension = size.height
scrollOffset = viewModel.scrollOffset
}
let numberOfItems = length.count
let start = index(for: abs(scrollOffset))
let endIndex = min(endIndex(for: start, screenWidth: screenDimension) + 1, numberOfItems)
let visibleRange = start.index ..< endIndex
let padding: Double
switch orientation {
case .horizontal:
padding = max(0, start.width - length[start.index])
case .vertical:
padding = max(0, start.width - length[start.index])
}
return StackView(orientation: orientation) {
Spacer()
.frame(
width: orientation == .horizontal ? padding : nil,
height: orientation == .vertical ? padding : nil
)
ForEach(visibleRange, id: \.hashValue) { index in
cellBuilder(index)
.frame(
width: orientation == .horizontal ? length[index] : nil,
height: orientation == .vertical ? length[index] : nil
)
.transition(
transition(index: index, start: start, endIndex: endIndex)
)
.background(Color.blue)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(x: orientation == .horizontal ? viewModel.scrollOffset : 0,
y: orientation == .vertical ? viewModel.scrollOffset : 0)
.contentShape(Rectangle())
.gesture(dragGesture(screenDimension: screenDimension))
}
public func orientation(_ orientation: Orientation) -> Self {
var view = self
view.orientation = orientation
return view
}
// MARK: Internal types
private class ViewModel: ObservableObject {
@Published var scrollOffset: Double = 0
var previousScrollOffset: Double = 0
var isLeadingAnimation: Bool? = nil
}
private struct Length: Sequence, IteratorProtocol {
let lengths: [Double]
let length: Double
let count: Int
let total: Double
private var currentIndex: Int = 0
init(lengths: [Double]) {
self.lengths = lengths
self.length = 0
self.count = lengths.count
self.total = lengths.reduce(0.0, +)
}
init(length: Double, numberOfItems: Int) {
self.lengths = []
self.length = length
self.count = numberOfItems
self.total = length * Double(numberOfItems)
}
subscript(index: Int) -> Double {
get {
if !lengths.isEmpty, index >= 0 && index < lengths.count {
return lengths[index]
}
return length
}
}
func makeIterator() -> Self {
return self
}
mutating func next() -> Double? {
guard currentIndex < count else {
return nil
}
defer { currentIndex += 1 }
return self[currentIndex]
}
}
}
// MARK: Vertical Previews
struct ClippedVerticalContentView: View {
var body: some View {
DynamicList(numberOfItems: 10000, itemLength: 80) { index in
Text("\(index)")
}
.orientation(.vertical)
.frame(width: 60, height: 200)
.border(Color.blue, width: 1)
.clipped()
}
}
struct NonClippedVerticalContentView: View {
var body: some View {
DynamicList(orientation: .vertical, numberOfItems: 10000, itemLength: 80) { index in
Text("\(index)")
}
.frame(width: 60, height: 200)
.border(Color.red, width: 1)
}
}
struct NonClippedDifferentWidthVerticalContentView: View {
var body: some View {
DynamicList(itemLengths: [120, 80, 40, 90, 220, 70, 80]) { index in
Text("\(index)")
}
.orientation(.vertical)
.frame(width: 60, height: 200)
.border(Color.red, width: 1)
}
}
struct VerticalContentView: View {
var body: some View {
HStack(spacing: 16) {
VStack {
Text("Clipped Vertical Content")
ClippedVerticalContentView()
}
VStack {
Text("Non Clipped Vertical Content")
NonClippedVerticalContentView()
}
VStack {
Text("Non Clipped Vertical Content With Different Item Size")
NonClippedDifferentWidthVerticalContentView()
}
}
.frame(width: 600)
.padding(.vertical, 100)
}
}
// MARK: Previews
//PlaygroundPage.current.setLiveView(HorizontalContentView())
PlaygroundPage.current.setLiveView(VerticalContentView())
MIT License
Copyright (c) 2023 shial4
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@BhushanaPannaga
Copy link

hey , i am facing an issue while implementing this HList, can get some help?

@shial4
Copy link
Author

shial4 commented Jul 28, 2022

@BhushanaPannaga how can I help you?

@shial4
Copy link
Author

shial4 commented Oct 5, 2023

updated code, to work with dynamic lengths (width or height) based on orientation

@shial4
Copy link
Author

shial4 commented Oct 21, 2023

Added scrollOffset binding which allows for programmatic offset manipulation

@nuhash-bcraft
Copy link

nuhash-bcraft commented Nov 21, 2023

Nice Implementation you did there! but Is it possible to achieve ScrollView like gesture feel here by using the location, translation, start location, time, velocity?

@shial4
Copy link
Author

shial4 commented Nov 22, 2023

@nuhash-bcraft Thanks for reaching out! Of course it is possible. Although, there is not muh information what parameters are used with in ScrollView to deliver such a feel. However, feel free to play with it and adjust everything as you feel.
Let me know, how did it go once you do it! :) Good Luck!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment