I decided to roll my own solution using some reflection. It supports both if
statements (conditional views) and ForEach
views as well as using the .tag()
modifier for identification.
private enum PagingTransition {
case next, previous
var value: AnyTransition {
switch self {
case .next:
return AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
case .previous:
return AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
}
}
}
private func isOptional(_ instance: Any) -> Bool {
let mirror = Mirror(reflecting: instance)
let style = mirror.displayStyle
return style == .optional
}
/// Erases generics to get views out of `ForEach`
fileprivate protocol ViewGeneratable {
func generateViews() -> [any View]
}
extension ForEach: ViewGeneratable {
func generateViews() -> [any View] {
self.data.map { self.content($0) as! any View }
}
}
/// A paging `TabView` replacement that doesn't allow for the user to interact
/// Follows SwiftUI calling conventions as best as possible with dirty reflection
/// https://www.fivestars.blog/articles/inspecting-views/
struct RestrictedPagingView<SelectionType: Hashable & Comparable, Content: View>: View {
let selection: SelectionType
@State private var selectionInternal: SelectionType
@State private var transition: AnyTransition = PagingTransition.next.value
private var views: [SelectionType: any View] = [:]
init(selection: SelectionType, @ViewBuilder content: () -> Content) {
self.selection = selection
self._selectionInternal = State(initialValue: selection)
// Attempt reflection
generateViews(from: content(), withBaseTag: selection)
}
/// This is the most big brain shit I've coded in a long time
/// Reflects SwiftUI views and puts them in a dictionary to use within the paging view
private mutating func generateViews(from instance: Any, withBaseTag baseTag: SelectionType) {
let mirror = Mirror(reflecting: instance)
// Is this a tuple view?
if let value = mirror.descendant("value") {
// Yes, so call this function recusrively until it isn't
let count = Mirror(reflecting: value).children.count
for i in 0..<count {
generateViews(from: mirror.descendant("value", ".\(i)")!, withBaseTag: baseTag)
}
} else if isOptional(instance) {
// This is an Optional, so check if it has a value
if let child = mirror.children.first?.value {
// It does, send it back through the function
generateViews(from: child, withBaseTag: baseTag)
}
} else if let content = mirror.descendant("content") {
// This is a ForEach loop, so unwrap and deal with all views separately
if mirror.descendant("contentID") != nil {
for view in (instance as! ViewGeneratable).generateViews() {
generateViews(from: view, withBaseTag: baseTag)
}
return
}
// This is a tagged view, extract the tag and the content and put them in the dictionary
let tag: SelectionType = mirror.descendant("modifier", "value", "tagged") as! SelectionType
views[tag] = (content as! any View)
} else {
// Just insert the view with a baseline tag
views[baseTag] = (instance as! any View)
}
}
// TODO: Handle removed conditional views more gracefully
var body: some View {
ForEach(views.keys.sorted(by: >), id: \.self) { idx in
if idx == selectionInternal {
AnyView(views[idx]!)
.transition(transition)
}
}
.onChange(of: selection) { newSelection in
if newSelection > selectionInternal {
transition = PagingTransition.next.value
} else {
transition = PagingTransition.previous.value
}
withAnimation(.easeInOut(duration: 0.25)) {
selectionInternal = newSelection
}
}
}
}
private struct RestrictedPagingViewPreview: View {
@State private var index = 1
@State private var allow = false
var body: some View {
VStack {
RestrictedPagingView(selection: index) {
ZStack {
Rectangle().foregroundColor(.blue)
Text("Hi")
}.tag(1)
ZStack {
Rectangle().foregroundColor(.green)
Text("Second")
}.tag(2)
ZStack {
Rectangle().foregroundColor(.red)
Text("Third")
}.tag(3)
ZStack {
Rectangle().foregroundColor(.yellow)
Button("FOURTH") {
print("button activated")
}
}.tag(4)
if allow {
ZStack {
Rectangle().foregroundColor(.orange)
Text("Should be hidden (5)")
}.tag(5)
ZStack {
Rectangle().foregroundColor(.purple)
Text("Should be hidden (6)")
}.tag(6)
}
ForEach(7..<11, id: \.self) { tagVal in
ZStack {
Rectangle().foregroundColor(.cyan)
Text("This view is generated by a ForEach loop! (\(tagVal))")
}
.tag(tagVal)
}
}
.border(Color.green)
Button("INCR") {
index += 1
}
Button("INCR 2") {
index += 2
}
Button("DECR") {
index -= 1
}
Button("DECR 2") {
index -= 2
}
Toggle("Show", isOn: $allow)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.border(Color.red)
}
}
struct RestrictedPagingView_Previews: PreviewProvider {
static var previews: some View {
RestrictedPagingViewPreview()
}
}
gesture
solution works! However, when there is a subview with egonTapGesture
, the subview still intercepts the swipe to change page. I'd like to have subviews that allow touch interaction :) – ZeelandText
elements don't have any subviews so you'd need to add the blocking gesture to other superviews as well – Tatiania