'SwiftUI: Make ScrollView scrollable only if it exceeds the height of the screen
Currently I have a view that looks like this.
struct StatsView: View {
var body: some View {
ScrollView {
Text("Test1")
Text("Test2")
Text("Test3")
}
}
}
This renders a view that contains 3 texts inside a scroll view, whenever I drag any of these texts in the screen the view will move cause its scrollable, even if these 3 texts fit in the screen and there is remaining space. What I want to achieve is to only make the ScrollView scrollable if its content exceeds the screen height size, if not, I want the view to be static and don't move. I've tried using GeometryReader and setting the scrollview frame to the screen width and height, also the same for the content but I continue to have the same behaviour, also I have tried setting the minHeight, maxHeight without any luck.
How can I achieve this?
Solution 1:[1]
Here is a possible approach if a content of scroll view does not require user interaction (as in PO question):
Tested with Xcode 11.4 / iOS 13.4
struct StatsView: View {
@State private var fitInScreen = false
var body: some View {
GeometryReader { gp in
ScrollView {
VStack { // container to calculate total height
Text("Test1")
Text("Test2")
Text("Test3")
//ForEach(0..<50) { _ in Text("Test") } // uncomment for test
}
.background(GeometryReader {
// calculate height by consumed background and store in
// view preference
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height) })
}
.onPreferenceChange(ViewHeightKey.self) {
self.fitInScreen = $0 < gp.size.height // << here !!
}
.disabled(self.fitInScreen)
}
}
}
Note: ViewHeightKey
preference key is taken from this my solution
Solution 2:[2]
For some reason I could not make work any of the above, but it did inspire me find a solution that did in my case. It's not as flexible as others, but could easily be adapted to support both axes of scrolling.
import SwiftUI
struct OverflowContentViewModifier: ViewModifier {
@State private var contentOverflow: Bool = false
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.background(
GeometryReader { contentGeometry in
Color.clear.onAppear {
contentOverflow = contentGeometry.size.height > geometry.size.height
}
}
)
.wrappedInScrollView(when: contentOverflow)
}
}
}
extension View {
@ViewBuilder
func wrappedInScrollView(when condition: Bool) -> some View {
if condition {
ScrollView {
self
}
} else {
self
}
}
}
extension View {
func scrollOnOverflow() -> some View {
modifier(OverflowContentViewModifier())
}
}
Usage
VStack {
// Your content
}
.scrollOnOverflow()
Solution 3:[3]
I've made a more comprehensive component for this problem, that works with all type of axis sets:
Code
struct OverflowScrollView<Content>: View where Content : View {
@State private var axes: Axis.Set
private let showsIndicator: Bool
private let content: Content
init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, @ViewBuilder content: @escaping () -> Content) {
self._axes = .init(wrappedValue: axes)
self.showsIndicator = showsIndicators
self.content = content()
}
fileprivate init(scrollView: ScrollView<Content>) {
self._axes = .init(wrappedValue: scrollView.axes)
self.showsIndicator = scrollView.showsIndicators
self.content = scrollView.content
}
public var body: some View {
GeometryReader { geometry in
ScrollView(axes, showsIndicators: showsIndicator) {
content
.background(ContentSizeReader())
.onPreferenceChange(ContentSizeKey.self) {
if $0.height <= geometry.size.height {
axes.remove(.vertical)
}
if $0.width <= geometry.size.width {
axes.remove(.horizontal)
}
}
}
}
}
}
private struct ContentSizeReader: View {
var body: some View {
GeometryReader {
Color.clear
.preference(
key: ContentSizeKey.self,
value: $0.frame(in: .local).size
)
}
}
}
private struct ContentSizeKey: PreferenceKey {
static var defaultValue: CGSize { .zero }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = CGSize(width: value.width+nextValue().width,
height: value.height+nextValue().height)
}
}
// MARK: - Implementation
extension ScrollView {
public func scrollOnlyOnOverflow() -> some View {
OverflowScrollView(scrollView: self)
}
}
Usage
ScrollView([.vertical, .horizontal]) {
Text("Ciao")
}
.scrollOnlyOnOverflow()
Attention
This code could not work in those situations:
- Content size change dynamically
- ScrollView size change dynamically
- Device orientation change
Solution 4:[4]
Building on Asperi's answer, we can conditionally wrap the view with a ScrollView
when we know the content is going to overflow. This is an extension to View you can create:
extension View {
func useScrollView(
when condition: Bool,
showsIndicators: Bool = true
) -> AnyView {
if condition {
return AnyView(
ScrollView(showsIndicators: showsIndicators) {
self
}
)
} else {
return AnyView(self)
}
}
}
and in the main view, just check if the view is too long using your logic, perhaps with GeometryReader
and the background color trick:
struct StatsView: View {
var body: some View {
VStack {
Text("Test1")
Text("Test2")
Text("Test3")
}
.useScrollView(when: <an expression you write to decide if the view fits, maybe using GeometryReader>)
}
}
}
Solution 5:[5]
I can't comment, because I don't have enough reputation, but I wanted to add a comment in the happymacaron answer. The extension worked for me perfectly, and for the Boolean to show or not the scrollView, I used the this code to know the height of the device:
///Device screen
var screenDontFitInDevice: Bool {
UIScreen.main.bounds.size.height < 700 ? true : false
}
So, with this var I can tell if the device height is less than 700, and if its true I want to make the view scrollable so the content can show without any problem.
So wen applying the extension I just do this:
struct ForgotPasswordView: View {
var body: some View {
VStack {
Text("Scrollable == \(viewModel.screenDontFitInDevice)")
}
.useScrollView(when: viewModel.screenDontFitInDevice, showsIndicators: false)
}
}
Solution 6:[6]
My solution does not disable content interactivity
struct ScrollViewIfNeeded<Content: View>: View {
@ViewBuilder let content: () -> Content
@State private var scrollViewSize: CGSize = .zero
@State private var contentSize: CGSize = .zero
var body: some View {
ScrollView(shouldScroll ? [.vertical] : []) {
content().readSize($contentSize)
}
.readSize($scrollViewSize)
}
private var shouldScroll: Bool {
scrollViewSize.height <= contentSize.height
}
}
struct SizeReaderModifier: ViewModifier {
@Binding var size: CGSize
func body(content: Content) -> some View {
content.background(
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
size = geometry.size
}
return Color.clear
}
)
}
}
extension View {
func readSize(_ size: Binding<CGSize>) -> some View {
self.modifier(SizeReaderModifier(size: size))
}
}
Usage:
struct StatsView: View {
var body: some View {
ScrollViewIfNeeded {
Text("Test1")
Text("Test2")
Text("Test3")
}
}
}
Solution 7:[7]
The following solution allows you to use Button inside:
Based on @Asperi solution
SpecialScrollView:
/// Scrollview disabled if smaller then content view
public struct SpecialScrollView<Content> : View where Content : View {
let content: Content
@State private var fitInScreen = false
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
if fitInScreen == true {
ZStack (alignment: .topLeading) {
content
.background(GeometryReader {
Color.clear.preference(key: SpecialViewHeightKey.self,
value: $0.frame(in: .local).size.height)})
.fixedSize()
Rectangle()
.foregroundColor(.clear)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
else {
GeometryReader { gp in
ScrollView {
content
.background(GeometryReader {
Color.clear.preference(key: SpecialViewHeightKey.self,
value: $0.frame(in: .local).size.height)})
}
.onPreferenceChange(SpecialViewHeightKey.self) {
self.fitInScreen = $0 < gp.size.height
}
}
}
}
}
struct SpecialViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
USE:
struct SwiftUIView6: View {
@State private var fitInScreen = false
var body: some View {
VStack {
Text("\(fitInScreen ? "true":"false")")
SpecialScrollView {
ExtractedView()
}
}
}
}
struct SwiftUIView6_Previews: PreviewProvider {
static var previews: some View {
SwiftUIView6()
}
}
struct ExtractedView: View {
@State var text:String = "Text"
var body: some View {
VStack { // container to calculate total height
Text(text)
.onTapGesture {text = text == "TextModified" ? "Text":"TextModified"}
Text(text)
.onTapGesture {text = text == "TextModified" ? "Text":"TextModified"}
Text(text)
.onTapGesture {text = text == "TextModified" ? "Text":"TextModified"}
Spacer()
//ForEach(0..<50) { _ in Text(text).onTapGesture {text = text == "TextModified" ? "Text":"TextModified"} } // uncomment for test
}
}
}
Solution 8:[8]
According to the Asperi! answer, I created a custom component that covers reported issue
private struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
struct SmartScrollView<Content: View>: View {
@State private var fitInScreen = false
@State var axes = Axis.Set.vertical
let content: () -> Content
var body: some View {
GeometryReader { gp in
ScrollView(axes) {
content()
.onAppear {
axes = fitInScreen ? [] : .vertical
}
.background(GeometryReader {
// calculate height by consumed background and store in
// view preference
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height) })
}
.onPreferenceChange(ViewHeightKey.self) {
self.fitInScreen = $0 < gp.size.height // << here !!
}
}
}
}
usage:
var body: some View {
SmartScrollView {
Content()
}
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | user16401900 |
Solution 3 | |
Solution 4 | happymacaron |
Solution 5 | Alessandro Pace |
Solution 6 | Nikaaner |
Solution 7 | Simone Pistecchia |
Solution 8 | Mohammad javad bashtani |