I have a basic horizontal scroll view where each image has the same height, but their widths can be different. Currently it seems like the view aligned modifier causes the images to align to the leading edge when the view is scrolled. If for example I click on an image (not current aligned view) from the edge of the screen then the scrollview will scroll to it and center align it, probably because of:
.scrollPosition(id: $detailScrollPosition, anchor: .center)
So I can center align the images by clicking them, how can I have it also center align for scrolling?
import SwiftUI
import Kingfisher
struct TopPostMediaView: View {
@State var detailScrollPosition: UUID? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
GeometryReader {
let size = $0.size
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(mynewtemp) { pic in
LazyHStack {
KFImage(URL(string: pic.photo))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: size.width)
.overlay(content: {
RoundedRectangle(cornerRadius: 10).stroke(Color.gray.opacity(0.4), lineWidth: 1.0)
})
.clipShape(RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)){
detailScrollPosition = pic.id
}
}
}
.frame(maxWidth: size.width)
.frame(height: size.height)
.contentShape(.rect)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $detailScrollPosition, anchor: .center)
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled()
}.frame(height: 250)
}
}
}
}
#Preview {
TopPostMediaView()
}
let mynewtemp: [tempStruct] = [tempStruct(id: UUID(), photo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRmCy16nhIbV3pI1qLYHMJKwbH2458oiC9EmA&s"), tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L"), tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L")]
struct tempStruct: Identifiable, Hashable {
var id: UUID
var photo: String
}
It seems that ViewAlignedScrollTargetBehavior
always aligns to the leading edge of the ScrollView
and there is no way to specify a different anchor.
When you set the scroll position using either .scrollPosition
or ScrollViewProxy.scrollTo
, it is possible to specify the anchor too. But this is unrelated to the scroll target behavior and doesn't have any impact on it.
To solve, you probably need to implement your own ScrollTargetBehavior
. For the case here, it is particularly difficult, because the container is lazy and every item can have a different size. But if you know the mid-X position of the currently selected image, this can be set as the target for scrolling to:
struct ScrollTargetCentered: ScrollTargetBehavior {
let currentMidX: CGFloat
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// Only perform an adjustment if the target anchor is
// top-leading. This will be the case for a scroll action,
// but may not be the case on initial show.
if (target.anchor?.x ?? 0) == 0 {
target.rect.origin.x = currentMidX - (context.containerSize.width / 2)
}
}
}
The image that is nearest the center is already being tracked using .scrollPosition
and its id is saved to detailScrollPosition
. So the mid-X position of the image with matching id can be recorded to a state variable as the view is scrolled, using a GeometryReader
behind each image. The changes needed:
Add a state variable for saving the mid-X position of the current image to.
Add a Namespace
property to use for naming the coordinate space of the LazyVStack
. You could just use a string, but a namespace avoids typos.
Change the HStack
to a LazyHStack
and remove the nested LazyHStack
(including its modifiers).
Name the coordinate space of the LazyHStack
using the Namespace
property.
Add a GeometryReader
behind each image to read the location within the coordinate space of the LazyHStack
and to record this location when the image is current.
Change the scrollTargetBehavior
to use ScrollTargetCentered
.
struct TopPostMediaView: View {
@State var detailScrollPosition: UUID? = nil
@State private var currentMidX = CGFloat.zero // π added
@Namespace private var ns // π added
private func positionTracker(imageId: UUID) -> some View { // π added
GeometryReader { proxy in
let midX: CGFloat? = imageId == detailScrollPosition
? proxy.frame(in: .named(ns)).midX
: nil
Color.clear
.onChange(of: midX) { oldVal, newVal in
if let newVal {
currentMidX = newVal
}
}
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
GeometryReader {
let size = $0.size
ScrollView(.horizontal) {
LazyHStack(spacing: 10) { // π changed
ForEach(mynewtemp) { pic in
// LazyHStack { // π removed (including modifiers)
// KFImage(URL(string: pic.photo))
Image(pic.photo) // π changed for testing
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: size.width)
.overlay(content: {
RoundedRectangle(cornerRadius: 10).stroke(Color.gray.opacity(0.4), lineWidth: 1.0)
})
.clipShape(RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)){
detailScrollPosition = pic.id
}
}
.background { positionTracker(imageId: pic.id) } // π added
// }
// .frame(maxWidth: size.width)
// .frame(height: size.height)
// .contentShape(.rect)
}
}
.scrollTargetLayout()
.coordinateSpace(name: ns) // π added
}
.scrollPosition(id: $detailScrollPosition, anchor: .center)
.scrollIndicators(.hidden)
// .scrollTargetBehavior(.viewAligned)
.scrollTargetBehavior( // π changed
ScrollTargetCentered(currentMidX: currentMidX)
)
.scrollClipDisabled()
}.frame(height: 250)
}
}
}
}
I found that the images you supplied in your example didn't always load properly, perhaps because images 2 + 3 have the same URL. So I tested with local images:
let mynewtemp: [tempStruct] = [
// tempStruct(id: UUID(), photo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRmCy16nhIbV3pI1qLYHMJKwbH2458oiC9EmA&s"),
// tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L"),
// tempStruct(id: UUID(), photo: "https://th.bing.com/th/id/OIG2.9O4YqGf98tiYzjKDvg7L")
tempStruct(id: UUID(), photo: "image1"),
tempStruct(id: UUID(), photo: "image2"),
tempStruct(id: UUID(), photo: "image3"),
tempStruct(id: UUID(), photo: "image4")
]
The custom scroll behavior performs quite well for slow scrolls, not so well for intertia scrolls. However, it won't work for narrow images at the start or end of the scrolled content. This is because a narrow image at an end won't reach the center of the screen, so detailScrollPosition
will never be updated with its id. To resolve this, you could consider adding padding to the LazyVStack
. See this answer for an example of where this technique is used.