Reputation: 111
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
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
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
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