19

I've been looking through the docs with each beta but haven't seen a way to make a traditional paged ScrollView. I'm not familiar with AppKit so I am wondering if this doesn't exist in SwiftUI because it's primarily a UIKit construct. Anyway, does anyone have an example of this, or can anyone tell me it's definitely impossible so I can stop looking and roll my own?

Brad
  • 655
  • 2
  • 7
  • 13
  • Not sure if there is any direct way to do the same but you can create your customView and achieve. Refer this link if it helps. https://stackoverflow.com/questions/56827148/how-to-create-grid-in-swiftui/56828239#56828239 – Hussain Shabbir Jul 05 '19 at 03:04

11 Answers11

26

You can now use a TabView and set the .tabViewStyle to PageTabViewStyle()

TabView {
            View1()
            View2()
            View3()
        }
        .tabViewStyle(PageTabViewStyle())
user1480779
  • 269
  • 3
  • 3
13

As of Beta 3 there is no native SwiftUI API for paging. I've filed feedback and recommend you do the same. They changed the ScrollView API from Beta 2 to Beta 3 and I wouldn't be surprised to see a further update.

It is possible to wrap a UIScrollView in order to provide this functionality now. Unfortunately, you must wrap the UIScrollView in a UIViewController, which is further wrapped in UIViewControllerRepresentable in order to support SwiftUI content.

Gist here

class UIScrollViewViewController: UIViewController {

    lazy var scrollView: UIScrollView = {
        let v = UIScrollView()
        v.isPagingEnabled = true
        return v
    }()

    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(self.scrollView)
        self.pinEdges(of: self.scrollView, to: self.view)

        self.hostingController.willMove(toParent: self)
        self.scrollView.addSubview(self.hostingController.view)
        self.pinEdges(of: self.hostingController.view, to: self.scrollView)
        self.hostingController.didMove(toParent: self)

    }

    func pinEdges(of viewA: UIView, to viewB: UIView) {
        viewA.translatesAutoresizingMaskIntoConstraints = false
        viewB.addConstraints([
            viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
            viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
            viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
            viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
        ])
    }

}

struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {

    var content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> UIScrollViewViewController {
        let vc = UIScrollViewViewController()
        vc.hostingController.rootView = AnyView(self.content())
        return vc
    }

    func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
        viewController.hostingController.rootView = AnyView(self.content())
    }
}

And then to use it:

    var body: some View {
        GeometryReader { proxy in
            UIScrollViewWrapper {
                VStack {
                    ForEach(0..<1000) { _ in
                        Text("Hello world")
                    }
                }
                .frame(width: proxy.size.width) // This ensures the content uses the available width, otherwise it will be pinned to the left
            }
        }
    }
arsenius
  • 12,090
  • 7
  • 58
  • 76
10

Apple's official tutorial covers this as an example. I find it easy to follow and suitable for my case. I really recommend you check this out and try to understand how to interface with UIKit. Since SwiftUI is so young, not every feature in UIKit would be covered at this moment. Interfacing with UIKit should address most if not all needs.

https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit

Junyi Wang
  • 154
  • 1
  • 6
  • 4
    The problem with that tutorial is that it only covers very basic functionality using a page view controller. When you start adding functionality like adding or deleting pages the provided code in the tutorial becomes obsolete pretty quickly. – alexisSchreier Dec 16 '19 at 08:26
7

Not sure if this helps your question but for the time being while Apple is working on adding a Paging View in SwiftUI I've written a utility library that gives you a SwiftUI feel while using a UIPageViewController under the hood tucked away.

You can use it like this:

Pages {
    Text("Page 1")
    Text("Page 2")
    Text("Page 3")
    Text("Page 4")
}

Or if you have a list of models in your application you can use it like this:

struct Car {
    var model: String
}

let cars = [Car(model: "Ford"), Car(model: "Ferrari")]

ModelPages(cars) { index, car in
    Text("The \(index) car is a \(car.model)")
        .padding(50)
        .foregroundColor(.white)
        .background(Color.blue)
        .cornerRadius(10)
}
McGuire
  • 1,192
  • 2
  • 11
  • 20
  • 1
    Awesome library! – alexisSchreier Dec 16 '19 at 08:51
  • Does the currentPage binding work for you? If i pass a binding to a state variable to the Pages view it works until i try to access this state variable (e.g. to show it as part of a `Text("Page \(currentPage)")`), after which i can't swipe more than one page anymore. Not sure whether this is a SwiftUI bug or something we shouldn't use like this, but i had the same problem when trying to wrap UIPageViewController in a SwiftUIy way. – cargath Feb 19 '20 at 09:47
  • Your custom function builder for arrays is great, though! Was trying to figure out how to do this all day :D – cargath Feb 19 '20 at 09:49
5

You can use such custom modifier:

struct ScrollViewPagingModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .onAppear {
                UIScrollView.appearance().isPagingEnabled = true
            }
            .onDisappear {
                UIScrollView.appearance().isPagingEnabled = false
            }
    }
}

extension ScrollView {
    func isPagingEnabled() -> some View {
        modifier(ScrollViewPagingModifier())
    }
}
2

You can simply track state using .onAppear() to load your next page.

struct YourListView : View {

    @ObservedObject var viewModel = YourViewModel()

    let numPerPage = 50

    var body: some View {
        NavigationView {
            List(viewModel.items) { item in
                NavigationLink(destination: DetailView(item: item)) {
                    ItemRow(item: item)
                    .onAppear {
                        if self.shouldLoadNextPage(currentItem: item) {
                            self.viewModel.fetchItems(limitPerPage: self.numPerPage)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Items"))
            .onAppear {
                guard self.viewModel.items.isEmpty else { return }
                self.viewModel.fetchItems(limitPerPage: self.numPerPage)
            }
        }
    }

    private func shouldLoadNextPage(currentItem item: Item) -> Bool {
        let currentIndex = self.viewModel.items.firstIndex(where: { $0.id == item.id } )
        let lastIndex = self.viewModel.items.count - 1
        let offset = 5 //Load next page when 5 from bottom, adjust to meet needs
        return currentIndex == lastIndex - offset
    }
}

class YourViewModel: ObservableObject {
    @Published private(set) items = [Item]()
    // add whatever tracking you need for your paged API like next/previous and count
    private(set) var fetching = false
    private(set) var next: String?
    private(set) var count = 0


    func fetchItems(limitPerPage: Int = 30, completion: (([Item]?) -> Void)? = nil) {
        // Do your stuff here based on the API rules for paging like determining the URL etc...
        if items.count == 0 || items.count < count {
            let urlString = next ?? "https://somePagedAPI?limit=/(limitPerPage)"
            fetchNextItems(url: urlString, completion: completion)
        } else {
            completion?(pokemon)
        }

    }

    private func fetchNextItems(url: String, completion: (([Item]?) -> Void)?) {
        guard !fetching else { return }
        fetching = true
        Networking.fetchItems(url: url) { [weak self] (result) in
            DispatchQueue.main.async { [weak self] in
                self?.fetching = false
                switch result {
                case .success(let response):
                    if let count = response.count {
                        self?.count = count
                    }
                    if let newItems = response.results {
                        self?.items += newItems
                    }
                    self?.next = response.next
                case .failure(let error):
                    // Error state tracking not implemented but would go here...
                    os_log("Error fetching data: %@", error.localizedDescription)
                }
            }
        }
    }
}

Modify to fit whatever API you are calling and handle errors based on your app architecture.

D.C.
  • 121
  • 1
  • 5
2

To simplify Lorenzos answer, you can basically add UIScrollView.appearance().isPagingEnabled = true to your scrollview as below:

  VStack{
            ScrollView(showsIndicators: false){
                VStack(spacing: 0){ //  to remove spacing between rows
                    ForEach(1..<10){ i in 
                        ZStack{
                            Text(String(i))
                            Circle()
                        } .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
                    }
                     
                }
            }.onAppear {
                UIScrollView.appearance().isPagingEnabled = true
            }
            .onDisappear {
                UIScrollView.appearance().isPagingEnabled = false
            } 
        }
2

Official support for having a paged ScrollView is in iOS 17, and was introduced at WWDC 2023.

You can use the new scrollTargetBehavior modifier (documentation) that takes as an argument a ScrollTargetBehavior. For paging purposes, pass it .paging.

They go into significant detail about how this works in the "Beyond scroll views" session.

Jeff C.
  • 2,031
  • 2
  • 17
  • 28
1

Checkout SwiftUIPager. It's a pager built on top of SwiftUI native components:

enter image description here

Fernando
  • 751
  • 2
  • 13
  • 27
  • Can you use this without pre-populating the pages? I have to display a large number of pages and I can not populate ahead of time. I need to be able to populate the page as the pager moves to that index. – nick Apr 28 '20 at 09:38
  • Hi @nick, yes, you should be able to. Prepopulate it with some elements and the use onPageChanged to append more – Fernando May 04 '20 at 08:35
  • Thanks Fernando! My app has a reading view that contains 1190 pages. Users can open multiple reading views so you most likely will have several views and each will be populated with 1190 pages. Wouldn't that be very inefficient? I wonder how the system will manage all the memory etc.... What are your thoughts on that? – nick May 14 '20 at 19:21
  • Hi @nick, the framework will just load enough reading-views to populate the space of 3 screens. After that, it asks for more pages on demand – Fernando May 19 '20 at 10:11
1

Horizontal/Vertical paging in iOS 17

From iOS 17, you can enable the paging behavior by applying the following modifier on the ScrollView:

.scrollTargetBehavior(.paging)
Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
0

If you would like to exploit the new PageTabViewStyle of TabView, but you need a vertical paged scroll view, you can make use of effect modifiers like .rotationEffect().

Using this method I wrote a library called VerticalTabView that turns a TabView vertical just by changing your existing TabView to VTabView.

Lorenzo Fiamingo
  • 3,251
  • 2
  • 17
  • 35