Programatically scroll to SwiftUI list position? [duplicate] - list

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)

Related

SwiftUI - Allow a List item to have both cell tap and button tap

I am trying to have a List that each item supports both cell tap and button tap
Currently, I have the following code, but for some reason, when I tap the hello button in each cell, although I do receive the action, but it also triggers the highlight from the background button, when i m tapping the hello button, I only want the highlight on the hello button, not on the whole cell
I searched everywhere can't really find an answer
Thanks in advance!
private enum MenuItem: String, Identifiable, CaseIterable {
var id: String {
self.rawValue
}
case option1
case option2
case option3
case option4
}
private struct MainView: View {
let onSelect: (MenuItem) -> Void
var body: some View {
VStack(spacing: 0) {
Text("Demo")
.padding(16)
List {
Section("hello") {
ForEach(MenuItem.allCases) { item in
ZStack {
Button("") {
print("yes", item)
}
VStack(alignment: .leading, spacing: 5) {
Text(item.rawValue).font(.title2)
Text("something here").font(.body)
Button("Hello") {
print("no", item)
}.buttonStyle(BorderedButtonStyle())
}
.contentShape(Rectangle())
.padding(.top, 10)
.padding(.bottom, 10)
}
}
}
}.listStyle(.plain)
}.onAppear {
UITableViewCell.appearance().selectedBackgroundView = {
let view = UIView()
view.backgroundColor = .blue
return view
}()
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(onSelect: { _ in })
}
}
Basically we need a custom button style in this situation (because automatic one is handled by List and gives observed behavior).
Here is main part. Tested with Xcode 13.4 / iOS 15.5
Button("") {
print("yes", item)
}
.buttonStyle(MyStyle()) // << here !!
// ...
struct MyStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.background( // highlight on row tap
configuration.isPressed ? Color.secondary : Color.clear
)
}
}
Complete test code on GitHub

Swiftui - Scrollview starting position [duplicate]

ScrollView(){
//no matter
}
Position is Top Left, but content will have smaller width than I needed
(2 strings in red rectangle must be single string, so need to enable both scrolls )
ScrollView([Axis.Set.horizontal, Axis.Set.vertical]) {
//no matter
}
In this case ScrollView's content have correct size for me.
But position of scrollView's content is centred ( both: horizontally and vertically )
How can I change default position to Top Left in case of ScrollView configured to both scrolls?
Update: Xcode 13.4 / macOS 12.4
The issue is still there, but now the solution is simpler using ScrollViewReader:
struct TestTwoAxisScrollView: View {
var body: some View {
ScrollViewReader { sp in
ScrollView([.horizontal, .vertical]) {
VStack {
ForEach(0..<100) { _ in
self.row()
}
}
.border(Color.green)
.id("root")
}
.border(Color.gray)
.padding()
.onAppear {
sp.scrollTo("root", anchor: .topLeading)
}
}
}
func row() -> some View {
Text(test)
.border(Color.red) // uncomment to see border
}
}
Original
Here is possible approach. Tested with Xcode 11.2 / macOS 10.15.3
Demo:
Code (complete testable module, borders are added for better visibility of each component):
import SwiftUI
let test = """
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12118" systemVersion="16A323" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
"""
struct ScrollViewHelper: NSViewRepresentable {
func makeNSView(context: NSViewRepresentableContext<ScrollViewHelper>) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async { // << must be async, so view got into view hierarchy
view.enclosingScrollView?.contentView.scroll(to: .zero)
view.enclosingScrollView?.reflectScrolledClipView(view.enclosingScrollView!.contentView)
}
return view
}
func updateNSView(_ nsView: NSView, context: NSViewRepresentableContext<ScrollViewHelper>) {
}
}
struct TestTwoAxisScrollView: View {
var body: some View {
ScrollView([.horizontal, .vertical]) {
VStack {
ForEach(0..<100) { _ in
self.row()
}
}
.background(ScrollViewHelper()) // << active part !!
.border(Color.green) // uncomment to see border
}
.border(Color.gray)
.padding()
}
func row() -> some View {
Text(test)
.border(Color.red) // uncomment to see border
}
}
struct TestTwoAxisScrollView_Previews: PreviewProvider {
static var previews: some View {
TestTwoAxisScrollView()
}
}

ScrollViewReader not scrolling programmatically

I have the following view and I'm attempting to have it scroll to the bottom on button click as elements are added to the list. I've searched and found that ScrollViewReader is the option to use however my implementation doesn't appear to be working.
My attempts at fixing have included explicitly setting the id of the cell on both the inner views as well as the outer HStack{} I even attempted to set the id to a reference of itself, kind of knowing that's a bad idea, but for brevity. I also removed any extra views inside of the list such as HStack{}, Spacer(), etc.. and just left my ColorsChosenView().id(i) thinking that extra views might cause it, but I digress the issue still persists.
var body: some View {
VStack {
ScrollViewReader { reader in
List {
ForEach(0..<vm.guesses.count, id: \.self) { i in
HStack{
Spacer()
ColorsChosenView(locationCorrect: 1,
locationIncorrect: 3,
color1: vm.guesses[i][0],
color2: vm.guesses[i][1],
color3: vm.guesses[i][2],
color4: vm.guesses[i][3])
Spacer()
}.id(i)
}
}.listStyle(InsetListStyle())
Divider()
.frame(maxWidth: 250)
ColorChoicePicker(vm: vm)
Divider()
.frame(maxWidth: 250)
HStack {
Spacer()
FABButton(text: "SUBMIT")
.onTapGesture {
vm.submit()
reader.scrollTo(vm.guesses.count - 1)
}
}.padding()
}
}
.navigationBarHidden(true)
.navigationBarHidden(true)
.onAppear(perform: {
vm.resetGame()
})
}
To simplify things, I found that this works just fine. Yet my implementation doesn't feel much different.
var body: some View {
ScrollViewReader { proxy in
VStack {
Button("Jump to #50") {
proxy.scrollTo(50)
}
List(0..<100, id: \.self) { i in
Text("Example \(i)")
.id(i)
}
}
}
}
Since you're modifying the array, this should work:
1: call the function in the main thread (DispatchQueue.main.async)
-> this will "kinda" work, it will scroll but not to the current but the previous last item
2: (Workaround) handle scrolling in a change-handler (you could also remove the shouldScroll variable if all changes should make it scroll to the bottom)
class NumbersContainer: ObservableObject {
#Published var numbers: [Int] = Array(0..<25)
func submit() {
self.numbers.append(self.numbers.count)
}
}
struct ContentView: View {
#StateObject var nc = NumbersContainer()
#State var shouldScroll: Bool = false
var body: some View {
VStack {
ScrollViewReader { reader in
Button("Submit", action: {
DispatchQueue.main.async {
nc.submit()
}
self.shouldScroll = true
})
List {
ForEach(0..<nc.numbers.count, id: \.self) { i in
HStack {
Spacer()
Text("Row \(i)")
Spacer()
}.id(i)
}
}
.onChange(of: nc.numbers) { newValue in
if shouldScroll {
reader.scrollTo(newValue.count - 1)
shouldScroll = false
}
}
}
}
}
}
Another Possibility would be to use the ScrollReaderProxy as a parameter of the submit function:
class NumbersContainer: ObservableObject {
#Published var numbers: [Int] = Array(0..<25)
func submit(reader: ScrollViewProxy) {
let dispatchGroup = DispatchGroup()
dispatchGroup.enter() // All leaves must have an enter
DispatchQueue.main.async {
self.numbers.append(self.numbers.count)
dispatchGroup.leave() // Notifies the DispatchGroup
}
dispatchGroup.notify(queue: .main) {
reader.scrollTo(self.numbers.count - 1)
}
}
}
struct ContentView: View {
#StateObject var nc = NumbersContainer()
var body: some View {
VStack {
ScrollViewReader { reader in
Button("Submit", action: {
nc.submit(reader: reader)
})
List {
ForEach(0..<nc.numbers.count, id: \.self) { i in
HStack {
Spacer()
Text("Row \(i)")
Spacer()
}.id(i)
}
}
}
}
}
}

SwiftUI Multiple Labels Vertically Aligned

There are a lot of solutions for trying to align multiple images and text in SwiftUI using a HStacks inside of a VStack. Is there any way to do it for multiple Labels? When added in a list, multiple labels automatically align vertically neatly. Is there a simple way to do this for when they are embedded inside of a VStack?
struct ContentView: View {
var body: some View {
// List{
VStack(alignment: .leading){
Label("People", systemImage: "person.3")
Label("Star", systemImage: "star")
Label("This is a plane", systemImage: "airplane")
}
}
}
So, you want this:
We're going to implement a container view called EqualIconWidthDomain so that we can draw the image shown above with this code:
struct ContentView: View {
var body: some View {
EqualIconWidthDomain {
VStack(alignment: .leading) {
Label("People", systemImage: "person.3")
Label("Star", systemImage: "star")
Label("This is a plane", systemImage: "airplane")
}
}
}
}
You can find all the code in this gist.
To solve this problem, we need to measure each icon's width, and apply a frame to each icon, using the maximum of the widths.
SwiftUI provides a system called “preferences” by which a view can pass a value up to its ancestors, and the ancestors can aggregate those values. To use it, we create a type conforming to PreferenceKey, like this:
fileprivate struct IconWidthKey: PreferenceKey {
static var defaultValue: CGFloat? { nil }
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
switch (value, nextValue()) {
case (nil, let next): value = next
case (_, nil): break
case (.some(let current), .some(let next)): value = max(current, next)
}
}
}
To pass the maximum width back down to the labels, we'll use the “environment” system. For that, we need an EnvironmentKey. In this case, we can use IconWidthKey again. We also need to add a computed property to EnvironmentValues that uses the key type:
extension IconWidthKey: EnvironmentKey { }
extension EnvironmentValues {
fileprivate var iconWidth: CGFloat? {
get { self[IconWidthKey.self] }
set { self[IconWidthKey.self] = newValue }
}
}
Now we need a way to measure an icon's width, store it in the preference, and apply the environment's width to the icon. We'll create a ViewModifier to do those steps:
fileprivate struct IconWidthModifier: ViewModifier {
#Environment(\.iconWidth) var width
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: IconWidthKey.self, value: proxy.size.width)
})
.frame(width: width)
}
}
To apply the modifier to the icon of each label, we need a LabelStyle:
struct EqualIconWidthLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon.modifier(IconWidthModifier())
configuration.title
}
}
}
Finally, we can write the EqualIconWidthDomain container. It needs to receive the preference value from SwiftUI and put it into the environment of its descendants. It also needs to apply the EqualIconWidthLabelStyle to its descendants.
struct EqualIconWidthDomain<Content: View>: View {
let content: Content
#State var iconWidth: CGFloat? = nil
init(#ViewBuilder _ content: () -> Content) {
self.content = content()
}
var body: some View {
content
.environment(\.iconWidth, iconWidth)
.onPreferenceChange(IconWidthKey.self) { self.iconWidth = $0 }
.labelStyle(EqualIconWidthLabelStyle())
}
}
Note that EqualIconWidthDomain doesn't just have to be a VStack of Labels, and the icons don't have to be SF Symbols images. For example, we can show this:
Notice that one of the label “icons” is an emoji in a Text. All four icons are laid out with the same width (across both columns). Here's the code:
struct FancyView: View {
var body: some View {
EqualIconWidthDomain {
VStack {
Text("Le Menu")
.font(.caption)
Divider()
HStack {
VStack(alignment: .leading) {
Label(
title: { Text("Strawberry") },
icon: { Text("🍓") })
Label("Money", systemImage: "banknote")
}
VStack(alignment: .leading) {
Label("People", systemImage: "person.3")
Label("Star", systemImage: "star")
}
}
}
}
}
}
This has been driving me crazy myself for a while. One of those things where I kept approaching it the same incorrect way - by seeing it as some sort of alignment configuration that was inside the black box that is List.
However it appears that it is much simpler. Within the List, Apple is simply applying a ListStyle - seemingly one that is not public.
I created something that does a pretty decent job like this:
public struct ListLabelStyle: LabelStyle {
#ScaledMetric var padding: CGFloat = 6
public func makeBody(configuration: Configuration) -> some View {
HStack {
Image(systemName: "rectangle")
.hidden()
.padding(padding)
.overlay(
configuration.icon
.foregroundColor(.accentColor)
)
configuration.title
}
}
}
This uses a hidden rectangle SFSymbol to set the base size of the icon. This is not the widest possible icon, however visually it seems to work well. In the sample below, you can see that Apple's own ListStyle assumes that the label icon will not be something significantly larger than the SFSymbol with the font being used.
While the sample here is not pixel perfect with Apple's own List, it's close and with some tweaking, you should be able to achieve what you are after.
By the way, this works with dynamic type as well.
Here is the complete code I used to generate this sample.
public struct ListLabelStyle: LabelStyle {
#ScaledMetric var padding: CGFloat = 6
public func makeBody(configuration: Configuration) -> some View {
HStack {
Image(systemName: "rectangle")
.hidden()
.padding(padding)
.overlay(
configuration.icon
.foregroundColor(.accentColor)
)
configuration.title
}
}
}
struct ContentView: View {
#ScaledMetric var rowHeightPadding: CGFloat = 6
var body: some View {
VStack {
Text("Lazy VStack Plain").font(.title2)
LazyVStack(alignment: .leading) {
ListItem.all
}
Text("Lazy VStack with LabelStyle").font(.title2)
LazyVStack(alignment: .leading, spacing: 0) {
vStackContent
}
.labelStyle(ListLabelStyle())
Text("Built in List").font(.title2)
List {
ListItem.all
labelWithHugeIcon
labelWithCircle
}
.listStyle(PlainListStyle())
}
}
// MARK: List Content
#ViewBuilder
var vStackContent: some View {
ForEach(ListItem.allCases, id: \.rawValue) { item in
vStackRow {
item.label
}
}
vStackRow { labelWithHugeIcon }
vStackRow { labelWithCircle }
}
func vStackRow<Content>(#ViewBuilder _ content: () -> Content) -> some View where Content : View {
VStack(alignment: .leading, spacing: 0) {
content()
.padding(.vertical, rowHeightPadding)
Divider()
}
.padding(.leading)
}
// MARK: List Content
var labelWithHugeIcon: some View {
Label {
Text("This is HUGE")
} icon: {
HStack {
Image(systemName: "person.3")
Image(systemName: "arrow.forward")
}
}
}
var labelWithCircle: some View {
Label {
Text("Circle")
} icon: {
Circle()
}
}
enum ListItem: String, CaseIterable {
case airplane
case people = "person.3"
case rectangle
case chevron = "chevron.compact.right"
var label: some View {
Label(self.rawValue, systemImage: self.rawValue)
}
static var all: some View {
ForEach(Self.allCases, id: \.rawValue) { item in
item.label
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
// .environment(\.sizeCategory, .extraExtraLarge)
}
}
Combining a few of these answers into another simple option (Very similar to some of the other options but thought it was distinct enough that some may find it useful). This has the simplicity of just setting a frame on the icon, and the swiftUI-ness of using LabelStyle but still adapts to dynamic type!
struct StandardizedIconWidthLabelStyle: LabelStyle {
#ScaledMetric private var size: CGFloat = 25
func makeBody(configuration: Configuration) -> some View {
Label {
configuration.title
} icon: {
configuration.icon
.frame(width: size, height: size)
}
}
}
The problem is that the system icons have different standard widths. It's probably easiest to use an HStack as you mentioned. However, if you use the full Label completion, you'll see that the Title is actually just a Text and the icon is just an Image... and you can then add custom modifiers, such as a specific frame for the image width. Personally, I'd rather just use an HStack anyway.
var body: some View {
VStack(alignment: .leading){
Label(
title: {
Text("People")
},
icon: {
Image(systemName: "person.3")
.frame(width: 30)
})
Label(
title: {
Text("Star")
},
icon: {
Image(systemName: "star")
.frame(width: 30)
})
Label(
title: {
Text("This is a plane")
},
icon: {
Image(systemName: "airplane")
.frame(width: 30)
})
}
}

Get onAppear behaviour from list in ScrollView in SwiftUI

When creating a List view onAppear triggers for elements in that list the way you would expect: As soon as you scroll to that element the onAppear triggers. However, I'm trying to implement a horizontal list like this
ScrollView(.horizontal) {
HStack(spacing: mySpacing) {
ForEach(items) { item in
MyView(item: item)
.onAppear { \\do something }
}
}
}
Using this method the onAppear triggers for all items at once, that is to say: immediately, but I want the same behavior as for a List view. How would I go about doing this? Is there a manual way to trigger onAppear, or control when views load?
Why I want to achieve this: I have made a custom Image view that loads an image from an URL only when it appears (and substitutes a placeholder in the mean time), this works fine for a List view, but I'd like it to also work for my horizontal 'list'.
As per SwiftUI 2.0 (XCode 12 beta 1) this is finally natively solved:
In a LazyHStack (or any other grid or stack with the Lazy prefix) elements will only initialise (and therefore trigger onAppear) when they appear on screen.
Here is possible approach how to do this (tested/worked with Xcode 11.2 / iOS 13.2)
Demo: (just show dynamically first & last visible cell in scrollview)
A couple of important View extensions
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
extension View {
func ifVisible(in rect: CGRect, in space: CoordinateSpace, execute: #escaping (CGRect) -> Void) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let frame = geometry.frame(in: space)
if frame.intersects(rect) {
execute(frame)
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
And a demo view of how to use them with cell views being in scroll view
struct TestScrollViewOnVisible: View {
#State private var firstVisible: Int = 0
#State private var lastVisible: Int = 0
#State private var visibleRect: CGRect = .zero
var body: some View {
VStack {
HStack {
Text("<< \(firstVisible)")
Spacer()
Text("\(lastVisible) >> ")
}
Divider()
band()
}
}
func band() -> some View {
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<50) { i in
self.cell(for: i)
.ifVisible(in: self.visibleRect, in: .named("my")) { rect in
print(">> become visible [\(i)]")
// do anything needed with visible rects, below is simple example
// (w/o taking into account spacing)
if rect.minX <= self.visibleRect.minX && self.firstVisible != i {
DispatchQueue.main.async {
self.firstVisible = i
}
} else
if rect.maxX >= self.visibleRect.maxX && self.lastVisible != i {
DispatchQueue.main.async {
self.lastVisible = i
}
}
}
}
}
}
.coordinateSpace(name: "my")
.rectReader(self.$visibleRect, in: .named("my"))
}
func cell(for idx: Int) -> some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color.yellow)
.frame(width: 80, height: 60)
.overlay(Text("\(idx)"))
}
}
I believe what you want to achieve can be done with LazyHStack.
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { value in
Text("Row \(value)")
.onAppear {
// Write your code for onAppear here.
}
}
}
}