Bidirectional infinite PageView in SwiftUI

Viewed 1273

I'm trying to make a bidirectional TabView (with .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))) whose datasource will change over time.

Below is the code that describes what is expected in the result:

class TabsViewModel: ObservableObject {
    @Published var items = [-2, -1, 0, 1, 2]
    @Published var selectedItem = 2 {
        didSet {
            if let index = items.firstIndex(of: selectedItem), index >= items.count - 2 {
                items = items + [items.last! + 1]
                items.removeFirst()
            }
            
            if let index = items.firstIndex(of: selectedItem), index <= 1 {
                items = [items.first! - 1] + items
                items.removeLast()
            }
        }
    }
}

struct TabsView: View {
    @StateObject var vm = TabsViewModel()
    
    var body: some View {
        TabView(selection: $vm.selectedItem) {
            ForEach(vm.items, id: \.self) { item in
                Text(item.description)
                    .frame(minWidth: 0,
                           maxWidth: .infinity,
                           minHeight: 0,
                           maxHeight: .infinity,
                           alignment: .topLeading
                    )
                    .background(Color(hue: .random(in: 0...0.99), saturation: .random(in: 0...0.99), brightness: 0.5))
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

In this case, when you try to go to next or previous page, the animation breaks off and the original page instantly returns to its place.

Is it possible to implement this exactly with TabView, without help of UIKit and third-party frameworks?

My guess is one of the possible solutions is to change the data source when the transition animation finishes, but I couldn't find a way to do this in SwiftUI.
Is my guess correct? Perhaps there are other more elegant ways to implement this functionality, and I'm coming from the wrong side.

1 Answers

The task itself can be solved using the SwiftUIPager framework.

import SwiftUI
import SwiftUIPager

class PagerConteinerViewModel: ObservableObject {
    @Published var items = [-3, -2, -1, 0, 1, 2, 3]
    @Published var selectedItemIndex = 3
    
    func updateItems (_ direction: Double) {
        withAnimation {
            if direction > 0 && selectedItemIndex >= items.count - 3 {
                items = items + [items.last! + 1]
                items.removeFirst()
            }
            
            if direction < 0 && selectedItemIndex <= 2 {
                items = [items.first! - 1] + items
                items.removeLast()
            }
        }
    }
}

struct PagerContainerView: View {
    @StateObject var vm = PagerConteinerViewModel()
    
    var body: some View {
        Pager(page: $vm.selectedItemIndex, data: vm.items,  id: \.self) { item in
            Text("\(item)")
                .frame(minWidth: 0,
                       maxWidth: .infinity,
                       minHeight: 0,
                       maxHeight: .infinity,
                       alignment: .topLeading
                )
                .background(Color(hue: .random(in: 0...0.99), saturation: .random(in: 0...0.99), brightness: 0.5))
        }
        .onDraggingEnded(vm.updateItems)
    }
}

The documentation uses onPageChanged to update the data source, but then inserting a value in the middle or beginning of the array will crash the application.

Related