I've written a view modifier that relies on the preference system to wrap a view in a ScrollView if it overflows the space it's allowed onscreen
struct ScrollableOverflow: ViewModifier {
// MARK: State Properties
/// Indicates if the content takes more vertical space than is available
@State private var isOverflowing = false
/// The vertical height of the content in pixels
@State private var childHeight: CGFloat? = nil
// MARK: Instance Properties
/**
The position of the content on the horizontal axis
Since `GeometryReader` takes up all the space it's offered and always left-aligns its conent, this is necessary to apply proper horizontal positioning to the rendered content
*/
let alignment: HorizontalAlignment
// MARK: Initializers
/**
Creates a new `ScrollableOverflow` modifier
- Parameter alignment: The position of the content on the horizontal axis
*/
init(alignment: HorizontalAlignment = .center) {
self.alignment = alignment
}
// MARK: ViewModifier Conformance
func body(content: Content) -> some View {
GeometryReader { availableGeometry in
HStack {
if alignment == .center || alignment == .trailing {
Spacer()
}
content
.fixedSize(horizontal: false, vertical: true)
.background(
GeometryReader { contentGeometry in
Color.clear
.preference(key: ScrollableChildViewHeight.self, value: dump(contentGeometry.size.height))
}
)
.onPreferenceChange(ScrollableChildViewHeight.self) { newChildHeight in
print("Modifier height: \(newChildHeight)")
childHeight = newChildHeight
isOverflowing = newChildHeight > availableGeometry.size.height
}
if alignment == .center || alignment == .trailing {
Spacer()
}
}
.scroll(when: isOverflowing)
}
.frame(maxHeight: childHeight)
}
// MARK: Helper Types
/// The preference key used to communicate the rendered height of the child view of a `ScrollableOverflow` view modifier back up to its parent to know if a `ScrollView` needs to be rendered
private struct ScrollableChildViewHeight: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
}
// MARK: - View Extension
extension View {
/**
Renders content in a scrollable way only if it overflows the vertical space it's allowed
- Parameter alignment: The position of the content on the horizontal axis
*/
func scrollOnOverflow(alignment: HorizontalAlignment = .center) -> some View {
modifier(ScrollableOverflow(alignment: alignment))
}
/**
Makes a view scrollable only when a condition applies
- Parameter condition: Determines if the view should be scrollable
*/
@ViewBuilder fileprivate func scroll(when condition: Bool) -> some View {
if condition {
ScrollView {
self
}
}
else {
self
}
}
}
This works for most views, but when I add a TextField to the hierarchy that overflows, the outer GeometryReader of the modifier ends up always getting its height set to 0
let designedLabel = Text("Email Address or Phone Number")
.font(.headline)
VStack(alignment: .leading, spacing: 20) {
Text("Please fill in whichever is connected to your account to receive a verification code")
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading) {
#if !os(macOS) // Platforms like macOS display the label given to the TextField directly, but not iOS
designedLabel
.fixedSize(horizontal: false, vertical: true)
#endif
TextField(text: $text, prompt: Text("you@example.com / +1 (555)-867-5309")) {
designedLabel
}
}
}
.padding()
.background(Color.gray)
.cornerRadius(20)
.scrollOnOverflow()
This seems to be because the ScrollableChildViewHeight preference always takes its defaultValue (which is 0, but you can verify this by changing it to whatever you'd like), and never gets updated with the correct value. However, if you look at the console with the dump and print statements that are there, you'll see that the preference does properly get set with what seems to be a correct value, though the call to onPreferenceChange never gets anything other than the defaultValue. Removing the TextField makes this system work seemingly flawlessly
Am I missing something or misunderstanding how the preference system works, or is this a SwiftUI bug? I'm using the latest Xcode version 13.2.1 (13C100) on the latest macOS Monterey 12.2 and targeting iOS 15.2, and this seems to be the case on all the simulators and my physical iPhone 12 Pro Max