So I've seen some approach to read the CGRect of a swiftui view using a technique where you setup a background containing a GeometryReader that is used to read the size of the view the background is set up. My issue is that this doesn't work if the view you're setting the background to has some ifs in it for example. See the code below for reference:
import SwiftUI
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct ChildRectReaderModifier: ViewModifier {
#Binding var rect: CGRect
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy in
Color.clear.preference(
key: RectPreferenceKey.self,
value: proxy.frame(in: .global)
)
}
)
.onPreferenceChange(RectPreferenceKey.self) { preferences in
if preferences != .zero && self.rect != preferences {
self.rect = preferences
}
}
}
}
extension View {
func childRectReader(_ rect: Binding<CGRect>) -> some View {
modifier(ChildRectReaderModifier(rect: rect))
}
}
struct ContentView: View {
#State var showOptionalText = false
#State var rect = CGRect.zero
var body: some View {
VStack {
Text("\(rect.width)")
Button {
showOptionalText.toggle()
} label: {
Text("Toggle")
}
VStack {
Text("Hello, world!")
Text("Hello, world!")
if showOptionalText {
Text("Hello, world!")
}
Text("Hello, world!")
}
.childRectReader($rect)
}
}
}
In the example above, the printed width is saying 0 unless I remove the if inside the inner VStack
Any idea why this happens? or any potential fixes?
As it is used in view modifier the preference is called several times on stack so reduce is active, but it is not correct here (just assign last one overriding all previous).
As I see for this scenario the fix is (tested with Xcode 13.4 / iOS 15.5)
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
// just remove anything from here !!
}
}
but if you wanted to handle multiple usage of this modifier for real views in same hierarchy then you have to use different model for this preference key (array, dictionary).
Related
I have reached an annoying issue with SwiftUI. I have a horizontal pager with vertical scroll views as pages. It is defined as simple as they come,
TabView(selection: $selected) {
ForEach(focus!.list.things) { thing in
FullView(thing: thing).tag(thing)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
and
struct FullView: View {
let thing: Thing
var body: some View {
ScrollView {
VStack {
...
}
}
}
}
This produces a view which does what I want, except it does not reach all the way down below the home indicator.
I can solve this by adding .ignoresSafeArea(edges: .bottom) to the TabView, but that produces another displeasing result where the page indicator collides with the home indicator.
Is there any reasonable way accomplish full height vertical scroll while keeping the index page indicator above the home indicator?
Code to recreate issue:
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
var body: some View {
NavigationView {
TabView() {
Page()
Page()
Page()
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
EDIT: See also #nekno's fantastic additions!
This is possible if you create a custom UIPageControl, manually tag each tab in the TabView, and make sure to keep track of the numberOfPages:
struct PageControlView: UIViewRepresentable {
#Binding var currentPage: Int
#Binding var numberOfPages: Int
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
#State var currentPage = 0
#State var numberOfPages = 3
var body: some View {
NavigationView {
ZStack {
TabView(selection: $currentPage) {
Page().tag(0)
Page().tag(1)
Page().tag(2)
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
VStack {
Spacer()
PageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
}
}
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#Coder-256's answer set me on the right path, and I added a couple enhancements you might find useful.
The UIPageControl normally iterates through the pages when you tap on it. As written, the indicator in the page control was changing, but the pages weren't actually changing, so I added a target for the page control's .valueChanged event.
When setting the current page based on the new changed value, wrapping the assignment in a withAnimation closure ensure the page animates to the next page, otherwise it just replaces the current page instantaneously.
TabView will work with any valid tag values, which just need to conform to Hashable.
To work with the page control, you need those tag values to be convertible to Int values, but it's common practice to use a strongly-typed, named value for tags, so I added support for an enum that conforms to RawRepresentable with a backing type of Int.
Others may find it easier to just use hard-coded integers for the tag values, so if you ever reordered the pages in your TabView you wouldn't have to remember to reorder the cases in your enum, but to each their own.
The UIPageControl and its parent ViewHost that hosts the UIViewRepresentable instance both have auto resizing masks that result in their frames expanding to consume the horizontal space of the containing superview.
Both the page control and the view host participate in hit testing, so they intercept touches to the left and right of the page control when you actually intend to scroll the content underneath.
Adding the allowsHitTesting(false) view modifier eliminates that behavior, but also disables all interaction with the page control, so it breaks the tap/paging functionality.
I played around with various solutions, and the easiest seems to be to just set a frame on the page control that requests a maxWidth and maxHeight of 0, and as a result the view shrinks to its intrinsic content size.
struct PageControlView<T: RawRepresentable>: UIViewRepresentable where T.RawValue == Int {
#Binding var currentPage: T
#Binding var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
#objc func valueChanged(sender: UIPageControl) {
guard let currentPage = T(rawValue: sender.currentPage) else {
return
}
withAnimation {
parent.currentPage = currentPage
}
}
}
}
struct ContentView: View {
#State private var currentPage: Pages = .myFirstPage
#State private var numberOfPages = Pages.allCases.count
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
MyFirstPage()
.tag(Pages.myFirstPage)
MySecondPage()
.tag(Pages.mySecondPage)
MyThirdPage()
.tag(Pages.myThirdPage)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
UIPageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
.frame(maxWidth: 0, maxHeight: 0)
.padding(22) // 22 seems to mimic SwiftUI's `PageIndexView` placement from the bottom edge
}
}
}
extension ContentView {
enum Pages: Int, CaseIterable {
case myFirstPage
case mySecondPage
case myThirdPage
}
}
I have an issue with the SwiftUI system layout on Xcode 12.5.1
On a Text View, I'm using a custom transition(), which is based on custom ViewModifiers. In addition, I'm also using a custom id() that allow me to tell the system that this Text View is not the same, therefore triggering a transition animation.
When using these 2 modifiers on a view, its parent and the content itself doesn't respect the system layout expected behaviour. You can see exactly what happens in this video
Expected behaviour
Actual behaviour
Here is my sample project code that you can test yourself :
import SwiftUI
struct ContentView: View {
private let contentArray: [String] = ["QWEQWE", "ASDASD", "TOTO", "FOO", "BAR"]
#State private var content: String = "content"
var body: some View {
VStack {
VStack {
Text(self.content)
// commenting either the transition modifier, or the id modifier, make the system layout work as expected. The problem comes from using both at the same time
.transition(.customTransition)
.id(self.content)
}
.background(Color.blue)
Button("action") {
guard let newValue = self.contentArray.randomElement() else {
print("Failed to get a random element from property contentArray")
return
}
withAnimation {
self.content = newValue
}
}
}
.background(Color.green)
}
}
extension AnyTransition {
static var customTransition: AnyTransition {
// uncomment the following line make the system layout work as expected. The problem comes from AnyTransition.modifier(active:identity:)
// return AnyTransition.slide.combined(with: .opacity)
let outAnimation = AnyTransition.modifier(active: CustomTrailingOffset(percent: 1), identity: CustomTrailingOffset(percent: 0)).combined(with: .opacity)
let inAnimation = AnyTransition.modifier(active: CustomLeadingOffset(percent: 1), identity: CustomLeadingOffset(percent: 0)).combined(with: .opacity)
return AnyTransition.asymmetric(insertion: inAnimation, removal: outAnimation)
}
}
struct CustomLeadingOffset: ViewModifier {
var percent: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geo in
content.offset(CGSize(width: -geo.size.width*self.percent, height: 0.0))
}
}
}
struct CustomTrailingOffset: ViewModifier {
var percent: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geo in
content.offset(CGSize(width: geo.size.width*self.percent, height: 0.0))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewLayout(.device)
.previewDevice("iPhone 12")
}
}
Tested on Xcode 12.5.1
I've found nothing regarding this behaviour during my researches, not on blog posts, not on forums, not on official documentation, that's the reason why I'm posting this question here, in case anyone ever encounter this problem, knows why it occurs, or knows a solution. I've fill a radar (FB9605660) to Apple in parallel but I'm not sure if I'll be informed to their conclusions.
I'm trying to build a button style in SwiftUI and I'd like to use the preference technique to retrieve the configuration label's size, as explained in this article.
But I have a weird issue where my code in onPreferenceChange which is supposed to change my size property just does not change it. You'll see this in sample code but I set the new value, and the line after I print the #State property and I don't get the value I just set!
import SwiftUI
struct MajorButtonStyle: ButtonStyle {
#State private var labelSize: CGSize = .zero
func makeBody(configuration: Configuration) -> some View {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor)
.overlay(
configuration.label
.foregroundColor(.white)
.padding()
.modifier(SizeModifier())
.onPreferenceChange(SizePreferenceKey.self) { value in
print("PREFERENCE HAS CHANGED!!!!!")
print(value)
self.labelSize = value
print(value.height)
print(self.labelSize.height)
}
)
.frame(height: labelSize.height)
}
}
// MARK: Modifier and preference key
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct SizeModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
}
}
func body(content: Content) -> some View {
content.background(sizeView)
}
}
Running the app and instantiating a button, here's the console output:
PREFERENCE HAS CHANGED!!!!!
(89.0, 52.33333333333333)
52.33333333333333
0.0 // 😫
Could someone tell me if I'm missing something? Or if I've fallen into some weird edge case, or bug? I'm testing this on Xcode 12 beta 2.
Thank you in advance.
What you try to do can be achieved in much simper way
struct MajorButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity) // << for all available width !!
.foregroundColor(.white)
.padding()
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor)
)
}
}
Note: btw, State wrapper works only in View, but ButtonStyle is not a view.
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?
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()
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
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")
}
}
}
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
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>)
}
}
}
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)
}
}
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
}
}
}
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()
}
}
Unfourtunatly none of the solutions here allow for dynamically responding to when turning on accessibility and increasing the font size on the fly. Hoping there will be a complete solution without disabling the UI within the scrollView.
This might help in case you need to listen on changes in font sizes, context changes etc. Simply just change the viewIndex to you needed identifier for changes.
This view will inform you about if it's scrolled or not, and also if the original content fits inside the scrollview or if it's scrollable.
Hope it helps someone :)
import Combine
import SwiftUI
struct FeedbackScrollView<Content: View>: View {
/// Used to inform the FeedbackScrollView if the view changes (mainly used in 'flows')
var viewIndex: Double
/// Notifies if the scrollview is scrolled
#Binding var scrollViewIsScrolled: Bool
/// Notifies if the scrollview has overflow in it's content, to indicate if it can scroll or now
#Binding var scrollViewCanScroll: Bool
/// The content you want to put into the scrollview.
#ViewBuilder private let content: () -> Content
public init(
viewIndex: Double = 0,
scrollViewIsScrolled: Binding<Bool> = .constant(false),
scrollViewCanScroll: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content
) {
self.viewIndex = viewIndex
self._scrollViewIsScrolled = scrollViewIsScrolled
self._scrollViewCanScroll = scrollViewCanScroll
self.content = content
}
var body: some View {
GeometryReader { geometry in
ScrollView {
offsetReader
content()
.frame(
minHeight: geometry.size.height,
alignment: .topLeading
)
.background(
GeometryReader { contentGeometry in
Color.clear
.onAppear {
scrollViewCanScroll = contentGeometry.size.height > geometry.size.height
}
.onChange(of: viewIndex) { _ in
scrollViewCanScroll = contentGeometry.size.height > geometry.size.height
}
}
)
}
.dismissKeyboardOnDrag()
.coordinateSpace(name: "scrollSpace")
.onPreferenceChange(OffsetPreferenceKey.self, perform: offsetChanged(offset:))
}
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .named("scrollSpace")).minY
)
}
.frame(height: 0)
}
private func offsetChanged(offset: CGFloat) {
withAnimation {
scrollViewIsScrolled = offset < 0
}
}
}
private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct FeedbackScrollView_Previews: PreviewProvider {
static var previews: some View {
FeedbackScrollView(
viewIndex: 0,
scrollViewIsScrolled: .constant(false),
scrollViewCanScroll: .constant(true)
) { }
}
}
Use it like this:
...
#State var scrollViewIsScrolled: Bool
#State var scrollViewCanScroll: Bool
FeedbackScrollView(
viewIndex: numberOfCompletedSteps,
scrollViewIsScrolled: $scrollViewIsScrolled,
scrollViewCanScroll: $scrollViewCanScroll
) {
// Your (scrollable) content goes here..
}
I have a view like below. I want to find out if it is the view which is displayed on the screen. Is there a function to achieve this?
struct TestView: View {
var body: some View {
Text("Test View")
}
}
You could use onAppear on any kind of view that conforms to View protocol.
struct TestView: View {
#State var isViewDisplayed = false
var body: some View {
Text("Test View")
.onAppear {
self.isViewDisplayed = true
}
.onDisappear {
self.isViewDisplayed = false
}
}
func someFunction() {
if isViewDisplayed {
print("View is displayed.")
} else {
print("View is not displayed.")
}
}
}
PS: Although this solution covers most cases, it has many edge cases that has not been covered. I'll be updating this answer when Apple releases a better solution for this requirement.
You can check the position of view in global scope using GeometryReader and GeometryProxy.
struct CustomButton: View {
var body: some View {
GeometryReader { geometry in
VStack {
Button(action: {
}) {
Text("Custom Button")
.font(.body)
.fontWeight(.bold)
.foregroundColor(Color.white)
}
.background(Color.blue)
}.navigationBarItems(trailing: self.isButtonHidden(geometry) ?
HStack {
Button(action: {
}) {
Text("Custom Button")
} : nil)
}
}
private func isButtonHidden(_ geometry: GeometryProxy) -> Bool {
// Alternatively, you can also check for geometry.frame(in:.global).origin.y if you know the button height.
if geometry.frame(in: .global).maxY <= 0 {
return true
}
return false
}
As mentioned by Oleg, depending on your use case, a possible issue with onAppear is its action will be performed as soon as the View is in a view hierarchy, regardless of whether the view is potentially visible to the user.
My use case is wanting to lazy load content when a view actually becomes visible. I didn't want to rely on the view being encapsulated in a LazyHStack or similar.
To achieve this I've added an extension onBecomingVisible to View that has the same kind of API as onAppear, but will only call the action when the view intersects the screen's visible bounds.
public extension View {
func onBecomingVisible(perform action: #escaping () -> Void) -> some View {
modifier(BecomingVisible(action: action))
}
}
private struct BecomingVisible: ViewModifier {
#State var action: (() -> Void)?
func body(content: Content) -> some View {
content.overlay {
GeometryReader { proxy in
Color.clear
.preference(
key: VisibleKey.self,
// See discussion!
value: UIScreen.main.bounds.intersects(proxy.frame(in: .global))
)
.onPreferenceChange(VisibleKey.self) { isVisible in
guard isVisible else { return }
action?()
action = nil
}
}
}
}
struct VisibleKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) { }
}
}
Discussion
I'm not thrilled by using UIScreen.main.bounds in the code! Perhaps a geometry proxy could be used for this instead, or some #Environment value – I've not thought about this yet though.