Created
September 22, 2022 15:13
-
-
Save BigZaphod/a85ca7f099a7b4ffcfe3b839ba6d5a16 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 | |
} |
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
The code for
PagingControl
is missing here.