SwiftUI makes designing user-interface elements like a breeze. It makes it so easy that we can just think about the features without worrying about the details.
In this post, we will look at how we can build a horizontal/vertical scroll list that will start from the opposite edge. For horizontal axis items should start from the right trailing edge and from bottom edge in vertical axis.
struct ReversedScrollView: View {
var body: some View {
}
}
Here we define our view struct, in which we will implement the scrollview as below.
struct ReversedScrollView: View {
var body: some View {
GeometryReader { proxy in
ScrollView(.horizontal) {
}
.background(Color.gray.opacity(0.3))
}
}
}
In above code we have used the GeometryReader
so that our scrollView can take the available size.
Then we are declaring our ScrollView
with horizontal scroll behaviour. Now we will need some content to show in scrollView.
struct ReversedScrollView<Content: View>: View {
var content: Content
init(@ViewBuilder builder: ()->Content) {
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(.horizontal) {
content
}
.background(Color.gray.opacity(0.3))
}
}
}
struct ReversedScrollView_Previews: PreviewProvider {
static var previews: some View {
ReversedScrollView {
Text("Hey scrollview")
}
}
}
In above code, we have the normal scroll view wrapper view. as you can see the scrollview shrunked to its content's size as per the SwiftUI's layout system.
Now we can start work to have the reversed/backward scrolling behaviour.
struct ReversedScrollView<Content: View>: View {
var content: Content
init(@ViewBuilder builder: ()->Content) {
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(.horizontal) {
HStack {
Spacer()
content
}
.background(Color.gray.opacity(0.2))
}
.background(Color.gray.opacity(0.3))
}
}
}
In this we embed our content
to HStack
with a Spacer()
so that we can have spacing on the left side of the content.
If we now build and run then it will not do as we expected. This is because HStack will layout it according to its children.
struct ReversedScrollView_Previews: PreviewProvider {
static var previews: some View {
ReversedScrollView {
ForEach(0..<3) { _ in
Text("Hey scrollview")
}
}
}
}
ReversedScrollView {
ForEach(0..<5) { _ in
Text("Hey scrollview")
}
}
As you can see the content is laying out from the leading. which is not opposite of what we are trying to achieve.
struct ReversedScrollView<Content: View>: View {
var content: Content
init(@ViewBuilder builder: ()->Content) {
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(.horizontal) {
HStack {
Spacer()
content
}
.frame(minWidth: proxy.size.width)
.background(Color.gray.opacity(0.2))
}
.background(Color.gray.opacity(0.3))
}
}
}
struct ReversedScrollView_Previews: PreviewProvider {
static var previews: some View {
ReversedScrollView {
ForEach(0..<5) { item in
Text("\(item)")
.padding()
.background(Color.gray.opacity(0.5))
.cornerRadius(6)
}
}
}
}
By constraining HStack
for the minimum width of the GeometryProxy's width
, our HStack
will now fit the whole available width. That will allow the spacer to send the content to the opposite edge.
Since we are setting the minimumWidth
, it will expand the width as the content grows.
That's it. We have it. A scrollView
with reversed scrolling.
Changing axis
Now we have implemented for horizontal axis. Lets go to support the both axis.
struct ReversedScrollView<Content: View>: View {
var axis: Axis.Set
var content: Content
init(_ axis: Axis.Set = .horizontal, @ViewBuilder builder: ()->Content) {
self.axis = axis
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(axis) {
HStack {
Spacer()
content
}
.frame(minWidth: proxy.size.width)
}
}
}
}
So In above code we added the axis
variable and initialised with the default `.horizontal` axis and updated our body property according to it. Now we need to update the HStack
to respect the requested axis.
So we can create new container view `Stack` which will take axis argument and return the appropriate Stack.
struct Stack<Content: View>: View {
var axis: Axis.Set
var content: Content
init(_ axis: Axis.Set = .vertical, @ViewBuilder builder: ()->Content) {
self.axis = axis
self.content = builder()
}
var body: some View {
switch axis {
case .horizontal:
HStack {
content
}
case .vertical:
VStack {
content
}
default:
VStack {
content
}
}
}
}
Here we are only supporting horizontal and vertical so we are sticking to VStack in default case.
Now updating our scrollview implementation.
struct ReversedScrollView<Content: View>: View {
var axis: Axis.Set
var content: Content
init(_ axis: Axis.Set = .horizontal, @ViewBuilder builder: ()->Content) {
self.axis = axis
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(axis) {
Stack(axis) {
Spacer()
content
}
.frame(minWidth: proxy.size.width)
}
}
}
}
Here we can see that it started from the top, but we want to start from the bottom. This is becuase of frame(minWidth:)
modifier we set for horizontal case. So we need to update that also.
What we want is to have minWidth
in case of horizontal axis and minHeight
in case of vertical axis. so in either of other value can be nil
in the frame modifier. So we add two functions like below.
func minWidth(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? {
axis.contains(.horizontal) ? proxy.size.width : nil
}
func minHeight(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? {
axis.contains(.vertical) ? proxy.size.height : nil
}
var body: some View {
GeometryReader { proxy in
ScrollView(axis) {
Stack(axis) {
Spacer()
content
}
.frame(
minWidth: minWidth(in: proxy, for: axis),
minHeight: minHeight(in: proxy, for: axis)
)
}
}
Updating the preview
struct ReversedScrollView_Previews: PreviewProvider {
static var previews: some View {
ReversedScrollView(.vertical) {
ForEach(0..<5) { item in
Text("\(item)")
.padding()
.background(Color.gray.opacity(0.5))
.cornerRadius(6)
}
.frame(maxWidth: .infinity)
}
}
}
Thats awesome. ๐คฉ
The Leading space
Here we can see the first item starts at the very leading, we want to have some spacing there, so we can have some additional views like a add button.
We can do that by adding the `minLengh` to the our spacer like below.
var body: some View {
GeometryReader { proxy in
ScrollView(axis) {
Stack(axis) {
Spacer(minLength: leadingSpace)
content
}
.frame(
minWidth: minWidth(in: proxy, for: axis),
minHeight: minHeight(in: proxy, for: axis)
)
}
}
}
Updating the initialiser
struct ReversedScrollView<Content: View>: View {
var axis: Axis.Set
var leadingSpace: CGFloat
var content: Content
init(_ axis: Axis.Set = .horizontal, leadingSpace: CGFloat = 10, @ViewBuilder builder: ()->Content) {
self.axis = axis
self.leadingSpace = leadingSpace
self.content = builder()
}
And the preview
struct ReversedScrollView_Previews: PreviewProvider {
static var previews: some View {
ReversedScrollView(.horizontal, leadingSpace: 50) {
ForEach(0..<12) { item in
Text("\(item)")
.padding()
.background(Color.gray.opacity(0.5))
.cornerRadius(6)
}
}
}
}
Use cases
1. Emoji Collector
struct EmojiCollector: View {
@State private var emojies: [String] = []
var body: some View {
ZStack {
ReversedScrollView(.horizontal, leadingSpace: 100) {
ForEach(emojies.reversed(), id: \.self) { emoji in
Text(emoji)
.font(.largeTitle)
.padding()
.background(Color.green.opacity(0.5))
.cornerRadius(8)
.transition(.move(edge: .bottom))
.frame(maxHeight: .infinity)
}
}
HStack {
Button(action: addEmoji) {
ZStack {
Circle()
.fill(Color.green)
.shadow(color: Color.green, radius: 4, x: 0, y: 0)
Image(systemName: "plus")
.font(.largeTitle)
.foregroundColor(.white)
}
}
.buttonStyle(PlainButtonStyle())
.frame(width: 60, height: 60)
Spacer()
}
.padding()
}
.frame(height: 150)
}
func addEmoji() {
withAnimation(.default) {
if let random = String.emojies.randomElement() {
self.emojies.append(random)
}
}
}
}
extension String {
static var emojies: [String] {
"๐,๐,๐,๐,๐,๐
,๐,๐คฃ,โบ๏ธ,๐,๐,๐,๐,๐,๐,๐,๐ฅฐ,๐,๐,๐,๐,๐,๐,๐,๐,๐คช,๐คจ,๐ง,๐ค,๐,๐คฉ,๐ฅณ,๐,๐,๐,๐,๐,๐,๐,โน๏ธ,๐ฃ,๐,๐ซ,๐ฉ,๐ฅบ,๐ข,๐ญ,๐ค,๐ ,๐ก,๐คฌ,๐คฏ,๐ณ,๐ฅต,๐ฅถ,๐ฑ,๐จ,๐ฐ,๐ฅ,๐,๐ค,๐ค,๐คญ,๐คซ,๐คฅ,๐ถ,๐,๐,๐ฌ,๐,๐ฏ,๐ฆ,๐ง,๐ฎ,๐ฒ,๐ฅฑ,๐ด,๐คค,๐ช,๐ต,๐ค,๐ฅด,๐คข,๐คฎ,๐คง,๐ท,๐ค,๐ค,๐ค"
.split(separator: ",")
.compactMap({"\($0)"})
}
}
2. Chat Screen
struct ChatView: View {
@State private var messages: [Message] = []
@State private var text: String = ""
@State private var targetMessage: Message?
var body: some View {
NavigationView {
VStack(spacing: 0) {
ScrollViewReader { scrollView in
ReversedScrollView(.vertical, showsIndicators: false) {
ForEach(messages) { message in
MessageView(message: message)
.transition(.move(edge: .bottom))
}
}
.padding(.horizontal)
.onChange(of: targetMessage) { message in
if let message = message {
targetMessage = nil
withAnimation(.default) {
scrollView.scrollTo(message.id)
}
}
}
HStack {
TextField("Message", text: $text)
.frame(height: 44)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
send()
}) {
Text("Send")
}
.buttonStyle(AppButtonStyle())
}
.padding(.horizontal)
}
}
.navigationBarTitle("Chat")
}
}
func send() {
guard text.hasText else { return }
let message = Message(id: UUID(), text: self.text, userId: "1", type: .random)
self.messages.append(message)
self.text = ""
self.targetMessage = message
}
}
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
ChatView()
}
}
extension Date {
static var formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "hh:mm"
return formatter
}()
}
// MARK: - Data
enum RecieptType: Int, Codable, Equatable {
case sent
case received
}
struct Message: Codable, Hashable, Identifiable {
var id: UUID
var text: String
var userId: String
var type: RecieptType
var date: Date = Date()
}
extension RecieptType {
var backgroundColor: Color {
switch self {
case .sent:
return .green
case .received:
return Color(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1))
}
}
var textColor: Color {
switch self {
case .sent:
return .white
case .received:
return .black
}
}
static var random: RecieptType {
let random = Int.random(in: 0...1)
return RecieptType(rawValue: random) ?? .sent
}
}
// MARK: - MessageView
struct MessageView: View {
var message: Message
var body: some View {
HStack {
if message.type == .sent {
Spacer(minLength: 16)
}
VStack(alignment: .trailing, spacing: 0) {
Text(message.text)
.padding(8)
Text("\(message.date, formatter: Date.formatter)")
.font(.system(size: 13))
.padding(6)
}
.background(message.type.backgroundColor)
.foregroundColor(message.type.textColor)
.cornerRadius(8)
if message.type == .received {
Spacer(minLength: 16)
}
}
.frame(maxWidth: .infinity)
.id(message.id)
}
}
Thanks for the reading.