Skip to content

Instantly share code, notes, and snippets.

@BigZaphod
Created September 22, 2022 15:13
Show Gist options
  • Save BigZaphod/a85ca7f099a7b4ffcfe3b839ba6d5a16 to your computer and use it in GitHub Desktop.
Save BigZaphod/a85ca7f099a7b4ffcfe3b839ba6d5a16 to your computer and use it in GitHub Desktop.
//
// PagingView.swift
// Wallaroo - https://wallaroo.app
//
// Created by Sean Heber (@BigZaphod) on 8/9/22.
//
import SwiftUI
// This exists because SwiftUI's paging setup is kind of broken and/or wrong out of the box right now.
//
// 1. One problem is that the existing TabView clips the content so we can't have page content expand behind bars and whatnot. Easily solved
// by using .ignoresSafeArea() but then...
//
// 2. TabView's built in in page control isn't positioned in the correct place if the TabView is being told to ignoreSafeArea() - which we want
// (as mentioned in point 1) so that the page content is behind a nav bar. So to work around THAT I'm using a custom PageControl. SIGH.
//
// 3. And speaking of clipping, it appears TabView doesn't clip it's content child pages to the size of the page so an over-large scaleToFill()
// on one page would leak into the next page visually. So I had to do that myself. Like... just... why? You had one job!
//
// 4. Another problem is that there's some kind of weird glitch where the TabView's content jumps when it is first touched like it was offset
// to make space for a nav bar or footer bar or something that isn't where it expected it to be. Wrapping the TabView in a ScrollView works
// around this problem but it sure is stupid.
//
// 5. And because the view gets wrapped in a scroll view, it doesn't know how big it should be, so then we need to measure the space with a
// GeometryReader and set the frame manually to compensate for that. Ugh what the hell? Seriously?
//
// All that said, though, this is still better than the original approach I had which was a totally custom page view control that tried to
// replicate things like the rubber band physics and the way the swipes and animations felt. At least by using this setup with some workarounds
// we get the real/correct iOS feel here (even if Apple changes it) without having to badly reverse engineer all of the physics and interactions.
//
// .... (*#$&*(#$#&$^&#($#^$(&*#
//
// 6. OMFG THERE IS SOME KIND OF RETAIN CYCLE OR SOMETHING WHAT THE ACTUAL FUCK I HATE THIS.
//
struct PagingView<Content, Page: View>: View where Content: RandomAccessCollection, Content.Index: Hashable {
private let content: Content
private let indexDisplayMode: PagingViewIndexDisplayMode
private let page: (Content.Element, Content.Index) -> Page
@Binding private var selectedPage: Int
@State private var feedbackGenerator = UISelectionFeedbackGenerator()
//
// OMFG I hate this so much. So the short story is that I think I found some kind of memory retain cycle
// when passing the selectedPage binding directly to the TabView. I still haven't quite worked out why
// or how it happens, but somehow this kept the TabView alive under the hood (since I assume it's actually
// implemented by wrapping something in UIKit, probably). That meant that an entire TabView and all the
// pages it contained leaked whenever you went into a detail view. Given that all these tab views have pages
// of huge images in them this of course was very bad. This horrible indirect state -> binding thing I've
// got going here seems to break the cycle somehow and keeps the problem from happening which immediately
// solved a whole host of memory usage problems.
//
// HOWEVER.... at the time of this writing, this leak or whatever still happens in the simulator! I don't
// know how or why that is, but on my iPad running iOS 16 beta 7 it does NOT seem to occur AS LONG AS THIS
// WORKAROUND IS HERE. If I remove the workaround, it still leaks like crazy. I'm at a total loss as to
// what the actual fuck is happening here and I'm about ready to throw my computer out the window and
// move to the moon to get away from all this bullshit. I'm so tired. I hate this.
//
@State private var selectedPageHack: Int
init(_ content: Content, selection: Binding<Int>, indexDisplayMode: PagingViewIndexDisplayMode = .automatic, @ViewBuilder page: @escaping (Content.Element, Content.Index) -> Page) {
self.content = content
self.indexDisplayMode = indexDisplayMode
self.page = page
self._selectedPage = selection
self._selectedPageHack = State(initialValue: selection.wrappedValue) // I hate this.
}
var body: some View {
GeometryReader { geometry in
ScrollView {
// Can't seem to use the selectedPage binding directly here or else we get some kind of retain
// cycle. See the long rant above. I feel like I'm going crazy. I hate this.
TabView(selection: $selectedPageHack) {
ForEach(content.indices, id: \.self) { idx in
page(content[idx], idx)
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
.tabViewStyle(.page(indexDisplayMode: .never))
}
.scrollDisabled(true)
}
.onChange(of: selectedPage) {
selectedPageHack = $0 // I hate this.
feedbackGenerator.selectionChanged()
}
.onChange(of: selectedPageHack) { // I hate this.
selectedPage = $0 // I hate this.
} // I hate this.
.ignoresSafeArea()
.overlay(alignment: .bottom) {
if isIndexVisible {
PagingControl(numberOfPages: content.count, selection: $selectedPage)
}
}
}
private var isIndexVisible: Bool {
switch indexDisplayMode {
case .automatic:
return content.count > 1
case .always:
return true
case .never:
return false
}
}
}
enum PagingViewIndexDisplayMode {
case automatic
case always
case never
}
@joshbirnholz
Copy link

The code for PagingControl is missing here.

@BigZaphod
Copy link
Author

There's nothing very special about my PagingControl and it could be better (no support for dragging like the real UIPageControl has, etc.) but here it is:

//
//  PagingControl.swift
//  Wallaroo
//
//  Created by Sean Heber (@BigZaphod) on 8/9/22.
//

import SwiftUI

// This is a custom control that's a bit like UIPageControl but not quite. We can customize this however we want, though.

struct PagingControl: View {
    let numberOfPages: Int
    
    @Binding var selection: Int

    @ScaledMetric(relativeTo: .subheadline) private var dotSize: CGFloat = 8

    private let padding: CGFloat = 5

    private var clampedDotSize: CGFloat {
        max(dotSize, 7)
    }

    var body: some View {
        HStack(spacing: 0) {
            Color.clear
                .contentShape(Rectangle())
                .onTapGesture {
                    withAnimation {
                        selection = max(0, selection - 1)
                    }
                }
            
            HStack(spacing: 0) {
                ForEach(0 ..< numberOfPages, id: \.self) { i in
                    Circle()
                        .foregroundColor(i == selection ? Color.white : Color.white.opacity(0.3))
                        .blendMode(i == selection ? .normal : .lighten)
                        .frame(width: clampedDotSize, height: clampedDotSize)
                        .padding(.all, padding)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            withAnimation {
                                selection = i
                            }
                        }
                }
            }
            .padding(padding / 2)
            
            Color.clear
                .contentShape(Rectangle())
                .onTapGesture {
                    withAnimation {
                        selection = min(selection + 1, numberOfPages - 1)
                    }
                }
        }
        .frame(height: clampedDotSize + padding * 4)
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment