I've build a ScrollView which contains 0-3 images and a multiline text field in a VStack. I also added a ScrollViewReader inside the scrollview and use it to scroll to the bottom of the text field upon certain events (user starts typing, image collection changes).
The point is: sometimes it works, sometimes it doesn't. When it does not work I realized, that when I scroll a little bit by hand and then try again (e.g. typing) it works.
Not sure if this is relevant, but ImageOrPlaceholderComponent first shows a placeholder as long as the image within currentEntryImages is nil, and the image after that (both states imply a change to currentEntryImages and should thus result in scrolling to the bottom of the text field).
NavigationStack {
ScrollView {
ScrollViewReader { scrollview in
VStack {
// Attached images.
AnyLayout(VStackLayout(spacing: 2.5)) {
ForEach(values: currentEntryImages) { entryImage in
ImageOrPlaceholderComponent(image: entryImage)
.clipped()
}
}
// Text field for the entry with toolbar.
TextField("...", text: $entryDTO.text, axis: .vertical)
.id(entryTextFieldAnchor)
.multilineTextAlignment(.leading)
.padding()
.focused($mainTextFieldFocused)
.onAppear { mainTextFieldFocused = true }
// Scroll to the bottom of the text field, when the user is typing ...
.onChange(of: entryDTO.text) { _ in
withAnimation {
scrollview.scrollTo(entryTextFieldAnchor, anchor: .bottom)
}
}
// ... or the entry images have changed.
.onChange(of: currentEntryImages) { _ in
withAnimation {
scrollview.scrollTo(entryTextFieldAnchor, anchor: .bottom)
}
}
}
}
}
}
I am writing an application using Xcode 14.0.1, and testing on an iPhone 12 mini running iOS 16.0. The current project build is for iOS 14.7. Here is my TabView...
TabView {
ByEyeView()
.tabItem { Label("ByEye", systemImage: "eye") }
ChartView()
.tabItem { Label("Chart", systemImage: "square.grid.4x3.fill") }
ListView()
.tabItem { Label("List", systemImage: "list.bullet") }
EditView()
.tabItem { Label("Edit", systemImage: "square.and.pencil") }
CameraView()
.tabItem { Label("Camera", systemImage: "camera") }
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
}
//.labelStyle(TitleAndIconLabelStyle())
//.padding(8)
//.ignoresSafeArea(edges: .bottom)
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
This gives a capsule at the bottom of the page with a small version of the icon and no text. I cannot enlarge the Label with .frame, and the .labelStyle() setting is ignored. I take it this is part of PageTabViewStyle() - the index is supposed to be small, and I can probably not change that. But the index sits over the view content, so I need its height if I am to keep buttons clear of it.
Can I find out the index height? Or does PageTabViewStyle assume that the index is small and you should work around it?
The commented-out .ignoreSafeArea() moves the index down while the page remains the same. The .padding() keeps it a bit clear of the bar at the bottom. This is what I am working with for now. This is foul: it will not work with other devices or screen orientations.
The bigger picture:
I have six entries. That does not fit in the default view, so I get a ... More tag which leads to an extra menu. Ugly. I like the PageTabViewStyle method of scrolling, but I want an index with a known height - preferably one that uses the full labels and sits at the bottom of the TabView layout, under the tabbed views.
This was one of those 'Magic Eye' things when you stare at it for days and it makes no sense, and suddenly everything rearranges itself...
Maybe TabPageViewStyle was intended to be for pages where there is no visible index, or overlaying a small index does no harm. This would work for browsing images. All the cunning has gone into making the index view unobtrusive. If you need to know how big it is, then perhaps TabPageViewStyle is not what you want.
What I said I wanted was actually a Scrollable horizontal list, followed by the currently selected list. Something like this...
let tabW = CGFloat(UIScreen.main.bounds.width / 5.0)
enum Page {
case ByEye
case Chart
case List
case Edit
case Camera
case Settings
}
#State private var page = Page.ByEye
func pageButton(_ select: Page, _ icon: String, _ title: String) -> some View {
return Button {
page = select
} label: {
VStack {
Image(systemName: icon)
Text(title)
} .frame(width: tabW)
} .foregroundColor( page == select ? Color.white : Color.gray )
}
var body: some View {
VStack() {
ScrollView(.horizontal) {
HStack() {
pageButton(Page.ByEye, "eye", "ByEye")
pageButton(Page.Chart, "square.grid.4x3.fill", "Chart")
pageButton(Page.List, "list.bullet", "List")
pageButton(Page.Edit, "square.and.pencil", "Edit")
pageButton(Page.Camera, "camera", "Camera")
pageButton(Page.Settings, "gear", "Settings")
}
}
switch page {
case .ByEye:
ByEyeView()
case .Chart:
ChartView()
case .List:
ListView()
case .Edit:
EditView()
case .Camera:
CameraView()
case .Settings:
SettingsView()
}
Spacer()
}
It is not much longer than my original version. It is not as pretty is it could be - when you overflow the title bar you get half an icon, where an ellipsis would be better. But I can fix that later.
The other answer is to write your own index table....
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack() {
pageButton(Page.EyeTest, "eyeglasses", "EyeTest", proxy)
pageButton(Page.Tone, "pause.rectangle", "Tone", proxy)
pageButton(Page.Chart, "square.grid.4x3.fill", "Chart", proxy)
pageButton(Page.ByEye, "eye", "ByEye", proxy)
pageButton(Page.List, "list.bullet", "List", proxy)
pageButton(Page.Camera, "camera", "Camera", proxy)
pageButton(Page.Settings, "gear", "Settings", proxy)
}
}
.onAppear { proxy.scrollTo(page, anchor: .center) }
.onChange(of: page) { page in
withAnimation {
proxy.scrollTo(page, anchor: .center)
}
}
}
This particular one has button-sized icons and text. 'page' is an enum, and also the tags of the TabView. If you stick it in the layout, you can make it fit around the TabView. You will want to hide the TabView index, which you can do with...
.tabViewStyle(PageTabViewStyle(indexDisplayMode:.never))
This is probably a custom view but in the Reddit app there's a toolbar and the top left button(3 lines) opens this kind of view from the side that moves the current view to the right so you can only see about 25% of it and a new view that takes up about 75% of the screen slides in. Is there anything like this built into SwiftUI and if there isn't how would I go about implementing something like this?
This is my custom side bar behave similarly to what you just mentioned, you can try it. (Images and Code are below)
Before click:
After clicked:
struct ContentView: View {
#State var isClicked = false
var body: some View {
HStack {
Rectangle()
.fill(.orange)
.frame(width: isClicked ? UIScreen.main.bounds.width * 0.75 : 0)
VStack {
HStack {
Button {
withAnimation {
isClicked.toggle()
}
} label: {
Image(systemName: "menucard.fill")
.padding(.leading)
}
Spacer()
}
Spacer()
}
}
}
}
Very novice to the app development game. I am trying to put this toolbar above the .decimalPad and I cannot get this large gap to go away.
VStack {
Rectangle()
.foregroundColor(Color(UIColor.systemBackground))
.frame(height: 35)
.overlay {
HStack {
Spacer()
Button(action: {
isTextFieldFocused = false
}) { Text("Done")}
}
.offset(y: -3)
.padding(.trailing)
}
.opacity(isTextFieldFocused ? 1 : 0)
.ignoresSafeArea(.keyboard) //This makes sure the bottom tab bar stays below the keyboard.
}
I initially thought it was something in another view causing the spacing, but I managed to parse through the views in the canvas and it does it regardless.
Here is what I'd like it to look like, for reference.
What I want
To add a Button onto your keyboard, you use a .toolbar with the locations to .keyboard like this:
TextField("Enter Text", text: $text)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button(action: {
isTextFieldFocused = false
}) { Text("Done")}
// If you want it leading, then use a Spacer() after
Spacer()
}
}
You were overthinking it by adding the Rectangle. This is why we look for minimal reproducible examples. We can dial in the fix for your specific code.
I'm not quite a SwiftUI veteran but I've shipped a couple of apps of moderate complexity. Still, I can't claim that I fully understand it and I'm hoping someone with deeper knowledge could shed some light on this issue:
I have some content that I want to toggle on and off, not unlike .sheet(), but I want more control over it. Here is some "reconstructed" code but it should be able capture the essence:
struct ContentView: View {
#State private var isShown = false
var body: some View {
GeometryReader { g in
VStack {
ZStack(alignment: .top) {
// This element "holds" the size
// while the content is hidden
Color.clear
// Content to be toggled
if self.isShown {
ScrollView {
Rectangle()
.aspectRatio(1, contentMode: .fit)
.frame(width: g.size.width) // This is a "work-around"
} // ScrollView
.transition(.move(edge: .bottom))
.animation(.easeOut)
}
} // ZStack
// Button to show / hide the content
Button(action: {
self.isShown.toggle()
}) {
Text(self.isShown ? "Hide" : "Show")
}
} // VStack
} // GeometryReader
}
}
What it does is, it toggles on and off some content block (represented here by a Rectangle within a ScrollView). When that happens, the content view in transitioned by moving in from the bottom with some animation. The opposite happens when the button is tapped again.
This particular piece of code works as intended but only because of this line:
.frame(width: g.size.width) // This is a "work-around"
Which, in turn, requires an extra GeometryReader, otherwise, the width of the content is animated, producing an unwanted effect (another "fix" I've discovered is using the .fixedSize() modifier but, to produce reasonable effects, it requires content that assumes its own width like Text)
My question to the wise is: is it possible to nicely transition in content encapsulated within a ScrollView without using such "fixes"? Alternatively, is there a more elegant fix for that?
A quick addition to the question following #Asperi's answer: contents should remain animatable.
You are my only hope,
–Baglan
Here is a solution (updated body w/o GeometryReader). Tested with Xcode 11.4 / iOS 13.4
var body: some View {
VStack {
ZStack(alignment: .top) {
// This element "holds" the size
// while the content is hidden
Color.clear
// Content to be toggled
if self.isShown {
ScrollView {
Rectangle()
.aspectRatio(1, contentMode: .fit)
.animation(nil) // << here !!
} // ScrollView
.transition(.move(edge: .bottom))
.animation(.easeOut)
}
} // ZStack
// Button to show / hide the content
Button(action: {
self.isShown.toggle()
}) {
Text(self.isShown ? "Hide" : "Show")
}
} // VStack
}