how to better implement reverse infinite scroll for conversation like screens - swift

In a chat-like app, the newest messages are shown at the bottom of the list. As you scroll up, the older messages are loaded and displayed, similar to the endless scrolling we’re accustomed to.
There are several options available to match this use case:
The first option is to use a List and invert it 180 degrees.
Another option is to use a ScrollView with a LazyVStack and invert them, similar to the List approach.
Another approach would be to cheat and fall back to the well-tested UIKit solutions.
Since SwiftUI is the future, I decided against the UIKit options (unless essential). and went for the ScrollView/LazyVStack option.
The problem is that when the items are prepended to the list, the ScrollView start-position (offset) is always the first item of the prepended list.
I cannot think of a non-hacky solution to force the ScrollView to stick with its initial offset (help is appreciated).
attaching an example code of both my reversed ScrollView and main ChatView screen.
struct ReversedScrollView<Content: View>: View {
var content: Content
init(#ViewBuilder builder: () -> Content) {
self.axis = axis
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(axis) {
VStack {
Spacer()
content
}
.frame(
minWidth: minWidth(in: proxy, for: axis),
minHeight: minHeight(in: proxy, for: axis)
)
}
}
}
}
public struct ChatView: View {
public var body: some View {
GeometryReader { geometry in
messagesView(in: geometry)
}
}
#ViewBuilder private func messagesView(in geometry: GeometryProxy) -> some View {
ReversedScrollView {
ScrollViewReader { proxy in
LazyVStack {
ForEach(messages) { message in
ChatMessageCellContainer(
message: message,
size: geometry.size,
)
.id(message.id)
}
}
}
}
}
}

Related

Is it possible to override SwiftUI modifiers?

Knowing that with SwiftUI view modifiers, order matters - because each modifier is a part of a chain of modifiers, I was wondering if it was possible to reset/overwrite/override a modifier (or the whole chain?
Specifically, I'm wondering about Styles (groupBoxStyle, buttonStyle, etc). I have default styles that I want to use in 90% of my app, and a few pages will have slightly different styles for those widgets.
For example:
// Renders a button with the "light" style
Button("Hello world") {
}
.buttonStyle(LightButtonStyle())
.buttonStyle(DarkButtonStyle())
// Renders a button with the "dark" style
Button("Hello world") {
}
.buttonStyle(DarkButtonStyle())
.buttonStyle(LightButtonStyle())
In those cases, I would actually like the 2nd modifier to be used, but the 1st takes over and subsequent styles don't work.
Note: In my actual app, none of my use cases are this trivial - this is just the simplest proof of concept.
The workaround(s) I have are that I create separate LightButton and DarkButton views, but that feels very inelegant (and becomes a mess when I have 5-6 variants of each component).
Alternatively, I have a custom MyButton(myStyle: ButtonStyle = .myDefaultStyle), but since this is a forms app, there are about 50-60 locations where something like that needs to be updated (instead of applying a modifier at a top level and letting that cascade through).
Edit: I should note, where I can set a top-level style and let it cascade, that works very well and as expected (closer to the View, the modifier takes over). But, there are just some weird use cases where it would be nice to flip the script.
Generally, buttonStyle propagates to child views, so ideally you would only need to set your “house style” once on the root view of your app.
The well-known place where this fails to work is the presentation modifiers like .sheet, which do not propagate styles to the presented view hierarchy. So you will need to write your own versions of the presentation modifiers that re-apply your house style.
For example, here's a custom ButtonStyle:
struct HouseButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(20)
.background {
Capsule(style: .continuous)
.foregroundColor(.pink)
}
.saturation(configuration.isPressed ? 1 : 0.5)
}
}
And here's a cover for sheet that applies the custom button style to the presented content:
extension View {
func houseSheet<Content: View>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
#ViewBuilder content: #escaping () -> Content
) -> some View {
return sheet(isPresented: isPresented, onDismiss: onDismiss) {
content()
.buttonStyle(HouseButtonStyle())
}
}
}
We can test out whether a NavigationLink, a sheet, and a houseSheet propagate the button style:
struct ContentView: View {
#State var showingHouseSheet = false
#State var showingStandardSheet = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Navigation Push") {
ContentView()
}
Button("Standard Sheet") {
showingStandardSheet = true
}
Button("House Sheet") {
showingHouseSheet = true
}
}
.sheet(isPresented: $showingStandardSheet) {
ContentView()
}
.houseSheet(isPresented: $showingHouseSheet) {
ContentView()
}
}
}
}
Here's the root view that applies the house button style at the highest level:
struct RootView: View {
var body: some View {
ContentView()
.buttonStyle(HouseButtonStyle())
}
}
If you play with this, you'll find that both NavigationLink and houseSheet propagate the button style to the presented content, but sheet does not.

How can we make a Custom GeometryReader in SwiftUI?

I was wondering how GeometryReader works under cover, and I am interested to build a custom GeometryReader for learning purpose!
Frankly I think every single view that we use in body is kind of GeometryReaderView with this difference that they do not use a closure for sending the proxy for us and it would annoying that every single view call back it's proxy! Therefore apple decided to give Geometry reading function to GeometryReader! So it is just my thoughts!
So I am looking a possible and also more likely SwiftUI-isch approach to reading proxy of view, or in other words see my codes in down:
struct ContentView: View {
var body: some View {
CustomGeometryReaderView { proxy in
Color.red
.onAppear() {
print(proxy)
}
}
}
}
struct CustomGeometryReaderView<Content: View>: View {
#ViewBuilder let content: (CGSize) -> Content
var body: some View {
// Here I most find a way to reading the available size for CustomGeometryReaderView and reporting it back!
return Color.clear.overlay(content(CGSize(width: 100.0, height: 100.0)), alignment: .topLeading)
}
}
Also I know that reading and reporting proxy of a view is not just the size of view, also it is about CoordinateSpace, frame ... But for now for making things easier to solve I am just working on size! So size matter!
As I said I am not interested to working with UIKit or UIViewRepresentable for reading the size! May apple using something like that under cover or may not!
My goal was trying solve the issue with pure SwiftUI or may some of you have some good link about source code of GeometryReader for reading and learning of it.
Ok, there are several instruments in SwiftUI providing access to view size (except GeometryReader of course).
The problem of course is to transfer that size value into view build phase, because only GeometryReader allows to do it in same build cycle.
Here is a demo of possible approach using Shape - a shape by design has no own size and consumes everything available, so covers all area, and has been provided that area rect as input.
Tested with Xcode 13 / iOS 15
struct CustomGeometryReaderView<Content: View>: View {
#ViewBuilder let content: (CGSize) -> Content
private struct AreaReader: Shape {
#Binding var size: CGSize
func path(in rect: CGRect) -> Path {
DispatchQueue.main.async {
size = rect.size
}
return Rectangle().path(in: rect)
}
}
#State private var size = CGSize.zero
var body: some View {
// by default shape is black so we need to clear it explicitly
AreaReader(size: $size).foregroundColor(.clear)
.overlay(Group {
if size != .zero {
content(size)
}
})
}
}
Alternate: same, but using callback-based pattern
struct CustomGeometryReaderView<Content: View>: View {
#ViewBuilder let content: (CGSize) -> Content
private struct AreaReader: Shape {
var callback: (CGSize) -> Void
func path(in rect: CGRect) -> Path {
callback(rect.size)
return Rectangle().path(in: rect)
}
}
#State private var size = CGSize.zero
var body: some View {
AreaReader { size in
if size != self.size {
DispatchQueue.main.async {
self.size = size
}
}
}
.foregroundColor(.clear)
.overlay(Group {
if size != .zero {
content(size)
}
})
}
}
Whenever I need to know the frame of a view, GeometryReader is just unnecessarily complicated and the 'Bound preference tried to update multiple times per frame' warnings are horrible. So I decided to create CustomGeometryReader

`withAnimation` only does animation once when adding first item to #State array

My goal is to have control over the type of animation when an object is added to the #State events array.
withAnimation only occurs on the first append to the events array. It is then ignored on additional appends.
I'm currently running this on Xcode 11 beta 4
I've tried adding the calling DispatchQueue.main.async, having the animation on the Text() object.
If I use a list it performs animation on addition, however I don't know how to modify those animations.
Goal
Have text slide in with each append and fade out on each remove.
struct Event: Identifiable {
var id = UUID()
var title: String
}
struct ContentView: View {
#State
var events = [Event]()
var body: some View {
VStack {
ScrollView {
ForEach(events) { event in
Text(event.title)
.animation(.linear(duration: 2))
}
}
HStack {
Button(action: {
withAnimation(.easeOut(duration: 1.5)) {
self.events.append(Event(title: "Animate Please"))
}
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}
I'm expecting that each append has an animation that is described in the withAnimation block.
When SwiftUI layouts and animations behave in ways you think are not correct, I suggest you add borders. The outcome may surprise you and point you directly into the cause. In most cases, you'll see that SwiftUI was actually right! As in your case:
Start by adding borders:
ScrollView {
ForEach(events) { event in
Text(event.title)
.border(Color.red)
.animation(.linear(duration: 2))
}.border(Color.blue)
}.border(Color.green)
When you run your app, you'll see that before adding your first array element, the ScrollView is collapsed into zero width. That is correct, as the ScrollView is empty. However, when you add your first element, it needs to be expanded to accommodate the "Animate Please" text. The Text() view also starts with zero width, but as its containing ScrollView grows, it does too. These are the changes that get animated.
Now, when you add your second element, there is nothing to animate. The Text() view is placed with its final size right from the start.
If instead of "Animate Please", you change your code to use a random length text, you will see that when adding a largest view, animations do occur. This is because ScrollView needs to expand again:
self.events.append(Event(title: String(repeating: "A", count: Int.random(in: 0..<20))))
What next: You have not explained in your question what animation you expect to see. Is it a fade-in? A slide? Note that in addition to animations, you may define transitions, which determines the type of animation to perform when a view is added or removed from your hierarchy.
If after putting these tips into practice, you continue to struggle, I suggest you edit your question and tell us exactly what animation would you like to see when adding a new element to your array.
UPDATE
According to your comments, you want the text to slide. The simplest form, is using a transition. Unfortunately, the ScrollView seems to disable transitions on its children. I don't know if that is intended or a bug. Anyway, here I post two methods. One with transitions (does not work with ScrollView) and one using only animations, which does work inside a ScrollView, but requires more code:
With transitions (does not work inside a ScrollView)
struct ContentView: View {
#State private var events = [Event]()
var body: some View {
VStack {
ForEach(events) { event in
// A simple slide
Text(event.title).transition(.slide).animation(.linear(duration: 2))
// To specify slide direction
Text(event.title).transition(.move(edge: .trailing)).animation(.linear(duration: 2))
// Slide combined with fade-in
Text(event.title).transition(AnyTransition.slide.combined(with: .opacity)).animation(.linear(duration: 2))
}
Spacer()
HStack {
Button(action: {
self.events.append(Event(title: "Animate Please"))
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}
Without transitions (works inside a ScrollView):
struct Event: Identifiable {
var id = UUID()
var title: String
var added: Bool = false
}
struct ContentView: View {
#State var events = [Event]()
var body: some View {
VStack {
ScrollView {
ForEach(0..<events.count) { i in
// A simple slide
Text(self.events[i].title).animation(.linear(duration: 2))
.offset(x: self.events[i].added ? 0 : 100).opacity(self.events[i].added ? 1 : 0)
.onAppear {
self.events[i].added = true
}
}
HStack { Spacer() } // This forces the ScrollView to expand horizontally from the start.
}.border(Color.green)
HStack {
Button(action: {
self.events.append(Event(title: "Animate Please"))
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}

How to define variables inside a GeometryReader in SwiftUI

I'd like to calculate the line width of a shape inside a view based on the view's size. Looking through various posts here on StackOverflow, I think the solution is to use a GeometryReader like this:
struct MyView: View {
var body: some View {
GeometryReader { geometry in
// Here goes your view content,
// and you can use the geometry variable
// which contains geometry.size of the parent
// You also have function to get the bounds
// of the parent: geometry.frame(in: .global)
}
}
}
My question is, how can I define variables inside the GeometryReader construct to be used for the view? I've tried to put a var statement directly after the line "GeometryReader { geometry in", but this gives a compiler error.
This seems to be a function builder related bug (as of Beta 3), and I recommend filing feedback on it.
The workaround I've been using is to use GeometryProxy in a separate method with an explicit return.
var body: some View {
GeometryReader { proxy in
self.useProxy(proxy)
}
}
func useProxy(_ proxy: GeometryProxy) -> some View {
var width = proxy.size.width
return VStack {
// use width in here
}
}

In SwiftUI, where are the control events, i.e. scrollViewDidScroll to detect the bottom of list data

In SwiftUI, does anyone know where are the control events such as scrollViewDidScroll to detect when a user reaches the bottom of a list causing an event to retrieve additional chunks of data? Or is there a new way to do this?
Seems like UIRefreshControl() is not there either...
Plenty of features are missing from SwiftUI - it doesn't seem to be possible at the moment.
But here's a workaround.
TL;DR skip directly at the bottom of the answer
An interesting finding whilst doing some comparisons between ScrollView and List:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
I appended a Rectangle at the end of 100 Text items inside a ScrollView, with a print in onDidAppear.
It fired when the ScrollView appeared, even if it showed the first 20 items.
All views inside a Scrollview are rendered immediately, even if they are offscreen.
I tried the same with List, and the behaviour is different.
struct ContentView: View {
var body: some View {
List {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
The print gets executed only when the bottom of the List is reached!
So this is a temporary solution, until SwiftUI API gets better.
Use a List and place a "fake" view at the end of it, and put fetching logic inside onAppear { }
You can to check that the latest element is appeared inside onAppear.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if let last == self.items.last {
print("last item")
self.items += last+1...last+30
}
}
}
}
}
}
In case you need more precise info on how for the scrollView or list has been scrolled, you could use the following extension as a workaround:
extension View {
func onFrameChange(_ frameHandler: #escaping (CGRect)->(),
enabled isEnabled: Bool = true) -> some View {
guard isEnabled else { return AnyView(self) }
return AnyView(self.background(GeometryReader { (geometry: GeometryProxy) in
Color.clear.beforeReturn {
frameHandler(geometry.frame(in: .global))
}
}))
}
private func beforeReturn(_ onBeforeReturn: ()->()) -> Self {
onBeforeReturn()
return self
}
}
The way you can leverage the changed frame like this:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(0..<100) { number in
Text("\(number)").onFrameChange({ (frame) in
print("Origin is now \(frame.origin)")
}, enabled: number == 0)
}
}
}
}
The onFrameChange closure will be called while scrolling. Using a different color than clear might result in better performance.
edit: I've improved the code a little bit by getting the frame outside of the beforeReturn closure. This helps in the cases where the geometryProxy is not available within that closure.
I tried the answer for this question and was getting the error Pattern matching in a condition requires the 'case' keyword like #C.Aglar .
I changed the code to check if the item that appears is the last of the list, it'll print/execute the clause. This condition will be true once you scroll and reach the last element of the list.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if item == self.items.last {
print("last item")
fetchStuff()
}
}
}
}
}
}
The OnAppear workaround works fine on a LazyVStack nested inside of a ScrollView, e.g.:
ScrollView {
LazyVStack (alignment: .leading) {
TextField("comida", text: $controller.searchedText)
switch controller.dataStatus {
case DataRequestStatus.notYetRequested:
typeSomethingView
case DataRequestStatus.done:
bunchOfItems
case DataRequestStatus.waiting:
loadingView
case DataRequestStatus.error:
errorView
}
bottomInvisibleView
.onAppear {
controller.loadNextPage()
}
}
.padding()
}
The LazyVStack is, well, lazy, and so only create the bottom when it's almost on the screen
I've extracted the LazyVStack plus invisible view in a view modifier for ScrollView that can be used like:
ScrollView {
Text("Some long long text")
}.onScrolledToBottom {
...
}
The implementation:
extension ScrollView {
func onScrolledToBottom(perform action: #escaping() -> Void) -> some View {
return ScrollView<LazyVStack> {
LazyVStack {
self.content
Rectangle().size(.zero).onAppear {
action()
}
}
}
}
}