Blur a view in SwiftUI based on condition - ios

Is there a way to conditionally blur a view in SwiftUI?
For example for VStack:
VStack {
//contents
}.blur(radius: 20)
Would blur the view. However, I'd like to blur it based on a condition.
I could use an if statement but that would mean writing the view twice in code like so:
if(shouldBlur)
{
VStack {
//contents
}.blur(radius: 20)
}
else
{
VStack {
//contents
}
}
Is this the only way to achieve the desired outcome?

We can just use 0 (no blur), like
VStack {
//contents
}.blur(radius: shouldBlur ? 20 : 0) // << here !!

There are a few different methods you could use. Methods 2 and 3 involve some duplication, but you can abstract that out of your direct view hierarchy.
struct ContentView: View {
#State private var isBlurred = false
var body: some View {
VStack {
Button("Toggle") { isBlurred.toggle() }
Text("Sample 1").blur(radius: isBlurred ? 20 : 0)
Text("Sample 2").modifier(ConditionalBlurred(isBlurred: isBlurred, radius: 20))
Text("Sample 3").conditionalBlur(isBlurred: isBlurred, radius: 20)
}
}
}
struct ConditionalBlurred: ViewModifier {
var isBlurred: Bool
var radius: CGFloat
func body(content: Content) -> some View {
if isBlurred {
content.blur(radius: radius)
} else {
content
}
}
}
extension View {
#ViewBuilder func conditionalBlur(isBlurred: Bool, radius: CGFloat) -> some View {
if isBlurred {
self.blur(radius: radius)
} else {
self
}
}
}

You can just store Views in variables, like so:
var viewToBlur: some View = {
//your View
}
And then use the same View twice in your body, like so:
if(shouldBlur)
{
VStack {
viewToBlur
}.blur(radius: 20)
}
else
{
VStack {
viewToBlur
}
}
And if your View needs dynamic properties you can use functions that return AnyView, like so:
func viewToBlur() -> AnyView{
return AnyView(
//your View
)
}

Related

How to prevent ScrollView to automatically show items added on top of inner ForEach

I'm building a bidirectional scrolling list of items and we need to add items to the head of the array, but the ScrollView is automatically scrolling down (probably to preserve the contentOffset?) and I can't achieve what I want, which would be that the items are ready to be displayed but only if the user scrolls up.
I'm not sure if there is a way to "fix" this by fiddling with ScrollView or if wrapping UIScrollView is just the easier way for now.
Snippet to reproduce:
struct ContentView: View {
#State var list = [1, 2, 3]
var body: some View {
VStack {
ScrollView {
LazyVStack {
ForEach(Array(zip(list.indices, list)), id: \.0) { index, element in
Text("\(element)")
}
}
}
.frame(maxHeight: 300)
Button(action: {
list = [Int.random(in: 1...1000)] + list
}) {
Text("Add")
.font(.title)
}
}
}
}
We can try this :
We need to read the offset of ScrollView's content. We'll use PreferenceKey.
private struct Offset: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
We need to store the new elements (when user taps on "Add") until they are added (when user scrolls to the top) :
struct MutableScrollView: View {
#State private var list = Array(1 ... 100)
#State private var elementsToAdd = Array<Int>()
#State private var onTop: Bool = true
onClick is called when the user taps the Button. offsetChanged when we scroll.
func addNewElements() {
if onTop {
list = elementsToAdd + list
elementsToAdd.removeAll()
}
}
func onClick() {
elementsToAdd.append(Int.random(in: 100 ... 1000))
addNewElements()
}
func offsetChanged(_ offset: CGPoint) {
onTop = offset.y >= 0
addNewElements()
}
The View :
var body: some View {
VStack {
ScrollView {
GeometryReader { geometry in
Color.clear.preference(
key: Offset.self,
value: geometry.frame(in: .named("scrollView")).origin
)
}.frame(width: 0, height: 0)
LazyVStack {
ForEach(Array(zip(list.indices, list)), id: \.1) { _, element in
Text("\(element)")
}
}
}
.frame(maxHeight: 300)
.coordinateSpace(name: "scrollView")
.onPreferenceChange(Offset.self) { offsetChanged($0) }
Button(action: onClick) {
Text("Add")
.font(.title)
}
}
}
}

Center SwiftUI view in top-level view

I am creating a loading indicator in SwiftUI that should always be centered in the top-level view of the view hierarchy (i.e centered in the whole screen in a fullscreen app). This would be easy in UIKit, but SwiftUI centres views relative to their parent view only and I am not able to get the positions of the parent views of the parent view.
Sadly my app is not fully SwiftUI based, so I cannot easily set properties on my root views that I could then access in my loading view - I need this view to be centered regardless of what the view hierarchy looks like (mixed UIKit - SwiftUI parent views). This is why answers like SwiftUI set position to centre of different view don't work for my use case, since in that example, you need to modify the view in which you want to centre your child view.
I have tried playing around with the .offset and .position functions of View, however, I couldn't get the correct inputs to always dynamically centre my loadingView regardless of screen size or regardless of what part of the whole screen rootView takes up.
Please find a minimal reproducible example of the problem below:
/// Loading view that should always be centered in the whole screen on the XY axis and should be the top view in the Z axis
struct CenteredLoadingView<RootView: View>: View {
private let rootView: RootView
init(rootView: RootView) {
self.rootView = rootView
}
var body: some View {
ZStack {
rootView
loadingView
}
// Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
.zIndex(.infinity)
}
private var loadingView: some View {
VStack {
Color.white
.frame(width: 48, height: 72)
Text("Loading")
.foregroundColor(.white)
}
.frame(width: 142, height: 142)
.background(Color.primary.opacity(0.7))
.cornerRadius(10)
}
}
View above which the loading view should be displayed:
struct CenterView: View {
var body: some View {
return VStack {
Color.gray
HStack {
CenteredLoadingView(rootView: list)
otherList
}
}
}
var list: some View {
List {
ForEach(1..<6) {
Text($0.description)
}
}
}
var otherList: some View {
List {
ForEach(6..<11) {
Text($0.description)
}
}
}
}
This is what the result looks like:
This is how the UI should look like:
I have tried modifying the body of CenteredLoadingView using a GeometryReader and .frame(in: .global) to get the global screen size, but what I've achieved is that now my loadingView is not visible at all.
var body: some View {
GeometryReader<AnyView> { geo in
let screen = geo.frame(in: .global)
let stack = ZStack {
self.rootView
self.loadingView
.position(x: screen.midX, y: screen.midY)
// Offset doesn't work either
//.offset(x: -screen.origin.x, y: -screen.origin.y)
}
// Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
.zIndex(.infinity)
return AnyView(stack)
}
}
Here is a demo of possible approach. The idea is to use injected UIView to access UIWindow and then show loading view as a top view of window's root viewcontroller view.
Tested with Xcode 12 / iOS 14 (but SwiftUI 1.0 compatible)
Note: animations, effects, etc. are possible but are out scope for simplicity
struct CenteredLoadingView<RootView: View>: View {
private let rootView: RootView
#Binding var isActive: Bool
init(rootView: RootView, isActive: Binding<Bool>) {
self.rootView = rootView
self._isActive = isActive
}
var body: some View {
rootView
.background(Activator(showLoading: $isActive))
}
struct Activator: UIViewRepresentable {
#Binding var showLoading: Bool
#State private var myWindow: UIWindow? = nil
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async {
self.myWindow = view.window
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let holder = myWindow?.rootViewController?.view else { return }
if showLoading && context.coordinator.controller == nil {
context.coordinator.controller = UIHostingController(rootView: loadingView)
let view = context.coordinator.controller!.view
view?.backgroundColor = UIColor.black.withAlphaComponent(0.8)
view?.translatesAutoresizingMaskIntoConstraints = false
holder.addSubview(view!)
holder.isUserInteractionEnabled = false
view?.leadingAnchor.constraint(equalTo: holder.leadingAnchor).isActive = true
view?.trailingAnchor.constraint(equalTo: holder.trailingAnchor).isActive = true
view?.topAnchor.constraint(equalTo: holder.topAnchor).isActive = true
view?.bottomAnchor.constraint(equalTo: holder.bottomAnchor).isActive = true
} else if !showLoading {
context.coordinator.controller?.view.removeFromSuperview()
context.coordinator.controller = nil
holder.isUserInteractionEnabled = true
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
var controller: UIViewController? = nil
}
private var loadingView: some View {
VStack {
Color.white
.frame(width: 48, height: 72)
Text("Loading")
.foregroundColor(.white)
}
.frame(width: 142, height: 142)
.background(Color.primary.opacity(0.7))
.cornerRadius(10)
}
}
}
struct CenterView: View {
#State private var isLoading = false
var body: some View {
return VStack {
Color.gray
HStack {
CenteredLoadingView(rootView: list, isActive: $isLoading)
otherList
}
Button("Demo", action: load)
}
.onAppear(perform: load)
}
func load() {
self.isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.isLoading = false
}
}
var list: some View {
List {
ForEach(1..<6) {
Text($0.description)
}
}
}
var otherList: some View {
List {
ForEach(6..<11) {
Text($0.description)
}
}
}
}

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?
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..
}

How to scroll List programmatically in SwiftUI?

It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.
So how would I do it in most simple & light way?
scroll List to end
scroll List to top
and others
(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)
Note: Row content design is out of consideration scope
Tested with Xcode 12b / iOS 14
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct ContentView: View {
#StateObject var vm = ScrollToModel()
let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
ScrollViewReader { sp in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}
SWIFTUI 1.0+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}
Solution:
Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.
struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader from iOS 14 and with Xcode 12
struct ContentView: View {
let items = (1...100)
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(items, id: \.self) { Text("\($0)"); Divider() }
}
HStack {
Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
}
}
}
}
Note that ScrollViewReader should support all scrollable content, but now it only supports ScrollView
Preview
Preferred way
This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
The strings should be defined in one place, outside of the body property.
Legacy answer
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself.
This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for MacOS:
struct ListScrollingHelper: NSViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView {
return NSView() // managed by SwiftUI, no overloads
}
func updateNSView(_ nsView: NSView, context: Context) {
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
}
}
class ListScrollingProxy {
//updated for mac osx
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView) {
//if nil == scrollView { //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height {
rect.origin.y = documentHeight - scroller.contentSize.height
}
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
}
}
}
extension NSView {
func enclosingScrollView() -> NSScrollView? {
var next: NSView? = self
repeat {
next = next?.superview
if let scrollview = next as? NSScrollView {
return scrollview
}
} while next != nil
return nil
}
}
my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top.
(this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )
struct ContentView: View {
#State private var previousIndex : Int? = nil
#State private var items = Array(0...100)
func removeRows(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
}
var body: some View {
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) { Text("\($0)")
}.onDelete(perform: removeRows)
}.onChange(of: previousIndex) { (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
}
}
}
}
This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { value in
VStack {
Button("Scroll to top") {
value.scrollTo(0)
}
Button("Scroll to buttom") {
value.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new scrollViewReader:
struct ScrollingHelperInjection: NSViewRepresentable {
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView {
return NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
helper.catchProxy(for: proxy)
}
}
final class ScrollingHelper {
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy) {
self.proxy = proxy
}
func scrollTo(_ point: Int) {
if let scroller = proxy {
withAnimation() {
scroller.scrollTo(point)
}
} else {
//problem
}
}
}
Environmental object:
#Published var scrollingHelper = ScrollingHelper()
In the view: ScrollViewReader { reader in .....
Injection in the view:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
Usage outside the view hierarchy: scrollingHelper.scrollTo(3)
As mentioned in #lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
Another cool way is to just use namespace wrappers:
A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
struct ContentView: View {
#Namespace private var topID
#Namespace private var bottomID
let items = (0..<100).map { $0 }
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Section {
LazyVStack {
ForEach(items.indices, id: \.self) { index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
}
}
} header: {
HStack {
Text("header")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(bottomID)
}
}
) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(topID)
} footer: {
HStack {
Text("Footer")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(topID) }
}
) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(bottomID)
}
.padding()
}
}
.foregroundColor(.white)
.background(.black)
}
}
Two parts:
Wrap the List (or ScrollView) with ScrollViewReader
Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().
The example below uses a notification for simplicity (use a function if you can instead!).
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)

How to check if a view is displayed on the screen? (Swift 5 and SwiftUI)

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.

Resources