Reputation: 6662
I have reached an annoying issue with SwiftUI. I have a horizontal pager with vertical scroll views as pages. It is defined as simple as they come,
TabView(selection: $selected) {
ForEach(focus!.list.things) { thing in
FullView(thing: thing).tag(thing)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
and
struct FullView: View {
let thing: Thing
var body: some View {
ScrollView {
VStack {
...
}
}
}
}
This produces a view which does what I want, except it does not reach all the way down below the home indicator.
I can solve this by adding .ignoresSafeArea(edges: .bottom)
to the TabView
, but that produces another displeasing result where the page indicator collides with the home indicator.
Is there any reasonable way accomplish full height vertical scroll while keeping the index page indicator above the home indicator?
Code to recreate issue:
struct ContentView: View {
@State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
var body: some View {
NavigationView {
TabView() {
Page()
Page()
Page()
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Upvotes: 8
Views: 3784
Reputation: 5618
EDIT: See also @nekno's fantastic additions!
This is possible if you create a custom UIPageControl
, manually tag each tab in the TabView
, and make sure to keep track of the numberOfPages
:
struct PageControlView: UIViewRepresentable {
@Binding var currentPage: Int
@Binding var numberOfPages: Int
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
struct ContentView: View {
@State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
@State var currentPage = 0
@State var numberOfPages = 3
var body: some View {
NavigationView {
ZStack {
TabView(selection: $currentPage) {
Page().tag(0)
Page().tag(1)
Page().tag(2)
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
VStack {
Spacer()
PageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
}
}
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Upvotes: 6
Reputation: 19267
@Coder-256's answer set me on the right path, and I added a couple enhancements you might find useful.
The UIPageControl
normally iterates through the pages when you tap on it. As written, the indicator in the page control was changing, but the pages weren't actually changing, so I added a target for the page control's .valueChanged
event.
When setting the current page based on the new changed value, wrapping the assignment in a withAnimation
closure ensure the page animates to the next page, otherwise it just replaces the current page instantaneously.
TabView
will work with any valid tag
values, which just need to conform to Hashable
.
To work with the page control, you need those tag values to be convertible to Int
values, but it's common practice to use a strongly-typed, named value for tags, so I added support for an enum
that conforms to RawRepresentable
with a backing type of Int
.
Others may find it easier to just use hard-coded integers for the tag values, so if you ever reordered the pages in your TabView
you wouldn't have to remember to reorder the cases in your enum
, but to each their own.
The UIPageControl
and its parent ViewHost
that hosts the UIViewRepresentable
instance both have auto resizing masks that result in their frames expanding to consume the horizontal space of the containing superview.
Both the page control and the view host participate in hit testing, so they intercept touches to the left and right of the page control when you actually intend to scroll the content underneath.
Adding the allowsHitTesting(false)
view modifier eliminates that behavior, but also disables all interaction with the page control, so it breaks the tap/paging functionality.
I played around with various solutions, and the easiest seems to be to just set a frame on the page control that requests a maxWidth
and maxHeight
of 0
, and as a result the view shrinks to its intrinsic content size.
struct PageControlView<T: RawRepresentable>: UIViewRepresentable where T.RawValue == Int {
@Binding var currentPage: T
@Binding var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
@objc func valueChanged(sender: UIPageControl) {
guard let currentPage = T(rawValue: sender.currentPage) else {
return
}
withAnimation {
parent.currentPage = currentPage
}
}
}
}
struct ContentView: View {
@State private var currentPage: Pages = .myFirstPage
@State private var numberOfPages = Pages.allCases.count
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
MyFirstPage()
.tag(Pages.myFirstPage)
MySecondPage()
.tag(Pages.mySecondPage)
MyThirdPage()
.tag(Pages.myThirdPage)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
UIPageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
.frame(maxWidth: 0, maxHeight: 0)
.padding(22) // 22 seems to mimic SwiftUI's `PageIndexView` placement from the bottom edge
}
}
}
extension ContentView {
enum Pages: Int, CaseIterable {
case myFirstPage
case mySecondPage
case myThirdPage
}
}
Upvotes: 3