Asio Otus
Asio Otus

Reputation: 111

Bidirectional infinite PageView in SwiftUI

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.

Upvotes: 7

Views: 4606

Answers (3)

latenitecoder
latenitecoder

Reputation: 756

This is not a submitted answer to the orginal post

I needed to implement something similar myself and decided to use @pd95's code which worked straight out of the box.

I wanted to introduce a calendar option, but as one was not provided I decided to make my own and also share it here in case anyone wants the same.

Using a similar approach to managing the state of the tab pages I set out to make a natural Swift solution to provide consecutive dates that coincide with the swiping forwards and backwards of the tabs.

A couple of modifications, the example provides 12 pages, which loop back from the 12th to the first. As I needed true infinite scrolling not governed by an array, I reduced this array to the minimum amount of pages I would need. The current page, previous, and next.

I also extended the system's Date object to hold a value that acts as the current displayed date, meaning it was easier to determine the previous and next day's dates.

@Binding var currentDate: Date is a property used to hold the current date value ready to be processed and displayed on scroll and then updated ready for the next scroll process.

The date defaults to todays date but can be changed easily by setting the targetedDate extension.

struct InfinitePageView<C, T>: View where C: View, T: Hashable {
    @Binding var selection: T
    @Binding var currentDate: Date

    let before: (T) -> T
    let after: (T) -> T

    @ViewBuilder let view: (T) -> C

    @State private var currentTab: Int = 0

    var body: some View {
        let previousIndex = before(selection)
        let nextIndex = after(selection)
    
        TabView(selection: $currentTab) {
            view(previousIndex)
                .tag(-1)
        
            view(selection)
                .onDisappear() {

                    if currentTab != 0 {
                        if currentTab < 0 {
                            currentDate = Date.targetedYesterday
                        } else {
                            currentDate = Date.targetedTomorrow
                        }
                        Date.targetedDate = currentDate
                    }

                    if currentTab != 0 {
                        selection = currentTab < 0 ? previousIndex : nextIndex
                        currentTab = 0
                    }
                }
                .tag(0)
        
            view(nextIndex)
                .tag(1)
        }
        .tabViewStyle(.page(indexDisplayMode: .never))
        .disabled(currentTab != 0) // FIXME: workaround to avoid glitch when swiping twice very quickly
    }
}


struct ListPageContainerView: View {

    private let systemColors: [Color] = [.red, .yellow, .green]

    @State private var colorIndex = 0
    @State private var currentDate = Date()


    var body: some View {
        InfinitePageView(
            selection: $colorIndex,
            currentDate: $currentDate,
            before: { correctedIndex(for: $0 - 1) },
            after: { correctedIndex(for: $0 + 1) },
            view: { index in
                systemColors[index]
                    .overlay(
                        Text(currentDate, format: .dateTime.day().month().year())
                            .colorInvert()
                    )
                    .font(.system(size: 50, weight: .heavy))
            }
        )
    }

    private func correctedIndex(for index: Int) -> Int {
        let count = systemColors.count
        return (count + index) % count
    }
}

Date Extension

extension Date {

    static var targetedDate: Date? = Date()
    static var targetedYesterday: Date { return targetedDate!.dayBefore }
    static var targetedTomorrow:  Date { return targetedDate!.dayAfter }

    var dayBefore: Date {
        return Calendar.current.date(byAdding: .day, value: -1, to: noon)!
    }
    var dayAfter: Date {
        return Calendar.current.date(byAdding: .day, value: 1, to: noon)!
    }
    var noon: Date {
        return Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: self)!
    }
}

Upvotes: 1

pd95
pd95

Reputation: 2642

For all those of you looking for a simple solution without external dependencies: I've just implemented my own variation, based on TabView and the .page tab view style. It is based on the idea that you have two functions before() and after() which return the index (!) of the page before or after the current selected page (which is stored in the selection). There is a view method which returns a view for a given page index.

So basically there are always three pages: the previous (tagged -1), the current (tagged 0) and the next (tagged 1) page. When the user swipes the page (=TabView content), the currentTab is updated by TabView(selection:). This currentTab is then propagated to the selection variable when the current page is off-screen (=onDisappear is called), updating the currentTab again to 0. Due to the newly set selection, the previous, current and next pages are regenerated.

The solution is using Swift generics. I've been using it with Int and Date indexes to render pages.

struct InfinitePageView<C, T>: View where C: View, T: Hashable {
    @Binding var selection: T

    let before: (T) -> T
    let after: (T) -> T

    @ViewBuilder let view: (T) -> C

    @State private var currentTab: Int = 0

    var body: some View {
        let previousIndex = before(selection)
        let nextIndex = after(selection)
        TabView(selection: $currentTab) {
            view(previousIndex)
                .tag(-1)

            view(selection)
                .onDisappear() {
                    if currentTab != 0 {
                        selection = currentTab < 0 ? previousIndex : nextIndex
                        currentTab = 0
                    }
                }
                .tag(0)

            view(nextIndex)
                .tag(1)
        }
        .tabViewStyle(.page(indexDisplayMode: .never))
        .disabled(currentTab != 0) // FIXME: workaround to avoid glitch when swiping twice very quickly
    }
}

Here is an example how to use it: It's an array of colors which is "cycling" / "rolling over" when you cross the boundaries (=start or end of array), so you can swipe through the colors forever in any direction.

struct Content: View {
    private let systemColors: [Color] = [
        .red, .orange, .yellow, .green,
        .mint, .teal, .cyan, .blue,
        .indigo, .purple, .pink, .brown
    ]

    @State private var colorIndex = 0

    var body: some View {
        InfinitePageView(
            selection: $colorIndex,
            before: { correctedIndex(for: $0 - 1) },
            after: { correctedIndex(for: $0 + 1) },
            view: { index in
                systemColors[index]
                    .overlay(
                        Text("\(index)     \(index)")
                            .colorInvert()
                    )
                    .font(.system(size: 100, weight: .heavy))
            }
        )
    }

    private func correctedIndex(for index: Int) -> Int {
        let count = systemColors.count
        return (count + index) % count
    }
}

A similar code can be used to swipe through a calendar or an endless list of appointments.

Upvotes: 17

Asio Otus
Asio Otus

Reputation: 111

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.

Upvotes: 3

Related Questions