ScrollView moves content over when scrolling - swift

When using a ScrollViewReader that is passed to another view, the views content moves over when scrolling. If the ForEach is inside the same view as the reader the view shift does not happen. When you manually scroll the views fix themselves. I have posted a simplified version below.
How would I stop the view from shifting over when the view is being scrolled?
I am using Xcode13 beta 5
struct ContentView: View {
var body: some View {
VStack {
ScrollView {
ScrollViewReader { proxy in
Button("Scroll to bottom") {
withAnimation {
proxy.scrollTo(99, anchor: .bottom)
}
}
TestView(proxy: proxy)
}
}
}
}
}
struct TestView: View {
var proxy: ScrollViewProxy
var body: some View {
VStack { ForEach(1..<100) { index in
Text("Test \(index)")
.id(index)
}
Button("Scroll to top") {
withAnimation {
proxy.scrollTo(1, anchor: .top)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

You can't have the views to scroll to, nested in another view.
Solution #1
Remove the VStack:
struct TestView: View {
let proxy: ScrollViewProxy
var body: some View {
ForEach(1..<100) { index in
Text("Test \(index)")
.id(index)
}
Button("Scroll to top") {
withAnimation {
proxy.scrollTo(1, anchor: .top)
}
}
}
}
Solution #2
Give the extracted view the content at the top of the ScrollView, by passing it in:
struct ContentView: View {
var body: some View {
VStack {
TestView { proxy in
Button("Scroll to bottom") {
withAnimation {
proxy.scrollTo(99, anchor: .bottom)
}
}
}
}
}
}
struct TestView<Content: View>: View {
#ViewBuilder let content: (ScrollViewProxy) -> Content
var body: some View {
ScrollView {
ScrollViewReader { proxy in
content(proxy)
ForEach(1..<100) { index in
Text("Test \(index)")
.id(index)
}
Button("Scroll to top") {
withAnimation {
proxy.scrollTo(1, anchor: .top)
}
}
}
}
}
}
I would recommend a slightly different way of doing this. If possible, it is probably best to have this in just one view. However, you want to reuse some of the components so this may not be what you are looking for.
Another thing I noticed: the scrolling doesn't go far enough to reach the buttons, which can appear underneath the safe-area and unable to be pressed. Make these changes:
Button("Scroll to bottom") {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.id("top")
Button("Scroll to top") {
withAnimation {
proxy.scrollTo("top", anchor: .top)
}
}
.id("bottom")

Related

SwiftUI - Loss of .onDelete Functionality in List as a result of DragGesture

The DragGesture() causes loss of functionality of the .onDelete function inside of the list. I made a basic example of the interaction:
class MenuViewModel: ObservableObject {
#Published var dragEnabled: Bool = true
}
struct RootView: View {
#StateObject var viewModel = MenuViewModel()
var body: some View {
ZStack {
NavView()
}
.environmentObject(viewModel)
// Can be diabled on ChildView, but will need to be re-enabled on RootView
.gesture(DragGesture()
.onChanged { _ in
guard viewModel.dragEnabled else { return }
print("Drag Gesture Active")
})
}
}
struct NavView: View {
var body: some View {
NavigationView {
NavigationLink {
ChildView()
} label: {
Text("Go to ChildView")
}
Text("Drag Gesture needs to be enabled")
}
.navigationViewStyle(.stack)
}
}
struct ChildView: View {
#EnvironmentObject var viewModel: MenuViewModel
#State var list = ["1", "2", "3"]
var body: some View {
List {
ForEach(list, id: \.self) { item in
Text(item)
}
.onDelete { indexSet in
print("OnDelete Works!")
list.remove(atOffsets: indexSet)
}
.navigationTitle("OnDelete Enabled")
}
.onAppear {
viewModel.dragEnabled = false
print("Drag Gesture Disabled")
}
.onDisappear {
viewModel.dragEnabled = true
print("Drag Gesture Enabled")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
RootView()
}
}
Attempted to use:
.simultaneously(with:
.highPriorityGesture(
.allowsHitTesting(false)
changing location of gesture modifier
creating custom gesture for swipe
disabling gesture if on specific screen (causes rootView to refresh)
Using ObservedObjects to pass value between views
Using EnvironmentObject to pass value into environment
Using Environment value to pass value into environment
.onReceive/.onAppear/.onDisappear/.onChange
Nothing is working as expected. Any suggestions would be appreciated! I know in this example the DragGesture does not do anything. I am using the DragGesture in my app so users can drag a side menu into view.
You can use GestureMask to enable/disable parent or child gestures. Do this in RootView:
var body: some View {
ZStack {
NavView()
}
.environmentObject(viewModel)
// Can be diabled on ChildView, but will need to be re-enabled on RootView
.gesture(DragGesture()
.onChanged { _ in
guard viewModel.dragEnabled else { return }
print("Drag Gesture Active")
}, including: viewModel.dragEnabled ? .gesture : .subviews) // here
}
}

Add a safe area to List after hiding the navigation bar in SwiftUI

I want to customize the navigation bar, but the refresh control is covered by the custom navigation bar after hiding the navigation. How can I adjust the position of the refresh control?
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
HomeNavigationBarView {
List {
NavigationLink {
EmptyView()
} label: {
Text("Hello, world!")
}
}
.listStyle(.insetGrouped)
.refreshable {
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here is my custom navbar
struct HomeNavigationBarView<Content: View>: View {
let content: () -> Content
var body: some View {
ZStack(alignment: .top) {
content()
HStack {
Image("logo")
.resizable()
.frame(width: 44, height: 44)
}
.frame(height: insetTop(), alignment: .bottom)
.frame(maxWidth: .infinity)
.background(.red.opacity(0.1))
}
.navigationBarHidden(true)
}
}
private func insetTop() -> CGFloat {
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
let window = windowScene?.windows.first
return window?.safeAreaInsets.top ?? 0 // 20或47
}
Padding should be there in List .padding(.top, insetTop()) to fix position of refresh control.
struct ContentView: View {
var body: some View {
NavigationView {
HomeNavigationBarView {
List {
NavigationLink {
EmptyView()
} label: {
Text("Hello, world!")
}
}
.padding(.top, insetTop())
.refreshable {
}
}
}
}
}
The way of adding safe area in iOS15+ perfectly solves this problem
List {
}
.safeAreaInset(edge: .top, spacing: 0) {
Rectangle()
.fill(.clear)
.frame(height: insetTop() + 44)
}

SwiftUI: Call a function of a programmatically created view

I am trying to make a SwiftUI ScrollView scroll to a certain point in an abstracted view when a button is pressed in a view which is calling the abstracted view programmatically. Here is my code:
struct AbstractedView: View {
#Namespace var view2ID
var body: some View {
ScrollView {
VStack {
View1()
View2()
.id(view2ID)
View3()
}
}
}
func scrollToView2(_ proxy: ScrollViewProxy) {
proxy.scrollTo(view2ID, anchor: .topTrailing)
}
}
As you can see, when scrollToView2() is called (in a ScrollViewReader), the AbstractedView scrolls to view2ID. I am creating a number of AbstractedView's programmatically in a different View:
struct HigherView: View {
var numAbstractedViewsToMake: Int
var body: some View {
VStack {
HStack {
ForEach (0..<numAbstractedViewsToMake, id: \.self) { _ in
AbstractedView()
}
}
Text("button")
.onTapGesture {
/* call each AbstractedView.scrollToView2()
}
}
}
}
If I stored these views in an array in a struct inside my HigherView with a ScrollViewReader for each AbstractedView would that work? I feel as though there has to be a nicer way to achieve this, I just have no clue how to do it. I am new to Swift so thank you for any help.
P.S. I have heard about UIKit but I don't know anything about it, is this the right time to be using that?
Using the comments from #Asperi and #jnpdx, I was able to come up with a more powerful solution than I needed:
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct HigherView: View {
#StateObject var vm = ScrollToModel()
var numAbstractedViewsToMake: Int
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()
HStack {
ForEach(0..<numAbstractedViewsToMake, id: \.self) { _ in
ScrollToModelView(vm: vm)
}
}
}
}
}
struct AbstractedView: View {
#ObservedObject var vm: ScrollToModel
let items = (0..<200).map { $0 } // this is his demo
var body: some View {
VStack {
ScrollViewReader { sp in
ScrollView {
LazyVStack { // this bit can be changed accordingly
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
}
}
}
}
}
}
}
}
Thank you both!

SwiftUI - ScrollView: onChange -> scrollTo cuts off text

Currently, I have a ScrollView with this code:
ScrollView {
ScrollViewReader { scrollView in
VStack {
ForEach(messages, id: \.id) { message in
MessageView(message)
}
MessageInput()
}
.onChange(of: messages.count) { _ in
scrollView.scrollTo(messages.last.id)
}
.onAppear { scrollView.scrollTo(messages.last.id) }
}
}
.onAppear() works as intended, giving this view:
However, when I send a new message and .onChange is called, I get this:
MessageView() does have padding, but I don't see how that would affect it only .onChange and not .onAppear.
How can I move the view down so that the message input box is included?
If you really want input be inside scroller then, probably, you need something like the following
ScrollView {
ScrollViewReader { scrollView in
VStack {
ForEach(messages, id: \.id) { message in
MessageView(message)
}
MessageInput().id("input")
}
.onChange(of: messages.count) { _ in
scrollView.scrollTo("input", anchor: .bottom)
}
.onAppear {
scrollView.scrollTo("input", anchor: .bottom)
}
}
}

Creating BaseView class in SwiftUI

Lately started learning/developing apps with SwiftUI and seems pretty easy to build the UI components. However, struggling creating a BaseView in SwiftUI. My idea is to have the common UI controls like background , navigation , etc in BaseView and just subclass other SwiftUI views to have the base components automatically.
Usually you want to either have a common behaviour or a common style.
1) To have a common behaviour: composition with generics
Let's say we need to create a BgView which is a View with a full screen image as background. We want to reuse BgView whenever we want. You can design this situation this way:
struct BgView<Content>: View where Content: View {
private let bgImage = Image.init(systemName: "m.circle.fill")
let content: Content
var body : some View {
ZStack {
bgImage
.resizable()
.opacity(0.2)
content
}
}
}
You can use BgView wherever you need it and you can pass it all the content you want.
//1
struct ContentView: View {
var body: some View {
BgView(content: Text("Hello!"))
}
}
//2
struct ContentView: View {
var body: some View {
BgView(content:
VStack {
Text("Hello!")
Button(action: {
print("Clicked")
}) {
Text("Click me")
}
}
)
}
}
2) To have a common behaviour: composition with #ViewBuilder closures
This is probably the Apple preferred way to do things considering all the SwiftUI APIs. Let's try to design the example above in this different way
struct BgView<Content>: View where Content: View {
private let bgImage = Image.init(systemName: "m.circle.fill")
private let content: Content
public init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body : some View {
ZStack {
bgImage
.resizable()
.opacity(0.2)
content
}
}
}
struct ContentView: View {
var body: some View {
BgView {
Text("Hello!")
}
}
}
This way you can use BgView the same way you use a VStack or List or whatever.
3) To have a common style: create a view modifier
struct MyButtonStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.red)
.foregroundColor(Color.white)
.font(.largeTitle)
.cornerRadius(10)
.shadow(radius: 3)
}
}
struct ContentView: View {
var body: some View {
VStack(spacing: 20) {
Button(action: {
print("Button1 clicked")
}) {
Text("Button 1")
}
.modifier(MyButtonStyle())
Button(action: {
print("Button2 clicked")
}) {
Text("Button 2")
}
.modifier(MyButtonStyle())
Button(action: {
print("Button3 clicked")
}) {
Text("Button 3")
}
.modifier(MyButtonStyle())
}
}
}
These are just examples but usually you'll find yourself using one of the above design styles to do things.
EDIT: a very useful link about #functionBuilder (and therefore about #ViewBuilder) https://blog.vihan.org/swift-function-builders/
I got a idea about how to create a BaseView in SwiftUI for common usage in other screen
By the way
Step .1 create ViewModifier
struct BaseScene: ViewModifier {
/// Scene Title
var screenTitle: String
func body(content: Content) -> some View {
VStack {
HStack {
Spacer()
Text(screenTitle)
.font(.title)
.foregroundColor(.white)
Spacer()
}.padding()
.background(Color.blue.opacity(0.8))
content
}
}
}
Step .2 Use that ViewModifer in View
struct BaseSceneView: View {
var body: some View {
VStack {
Spacer()
Text("Home screen")
.font(.title)
Spacer()
}
.modifier(BaseScene(screenTitle: "Screen Title"))
}
}
struct BaseSceneView_Previews: PreviewProvider {
static var previews: some View {
Group {
BaseSceneView()
}
}
}
Your Output be like: