I have a header that is fixed in place using an offset relative to the scroll position. Strangely enough though, when the contents of the scroll view has a dynamic opacity to the buttons, the offset is very jumpy:
This is the scroll view code, the HeaderView is "fixed" in place by pinning the offset to the scroll view's offset. The opacity seems to be causing the performance issue is on the MyButtonStyle style on the last line of code:
struct ContentView: View {
#State private var isPresented = false
#State private var offsetY: CGFloat = 0
#State private var headerHeight: CGFloat = 200
var body: some View {
GeometryReader { screenGeometry in
ZStack {
Color(.label)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 0.0) {
Color.clear
.frame(height: headerHeight)
.overlay(
HeaderView(isPresented: $isPresented)
.offset(y: offsetY != 0 ? headerHeight + screenGeometry.safeAreaInsets.top - offsetY : 0)
)
VStack(spacing: 16) {
VStack(alignment: .leading) {
ForEach(0...10, id: \.self) { index in
Button("Button \(index)") {}
.buttonStyle(MyButtonStyle(icon: Image(systemName: "alarm")))
}
}
Spacer()
}
.frame(maxWidth: .infinity, minHeight: screenGeometry.size.height)
.padding()
.background(
GeometryReader { geometry in
Color.white
.cornerRadius(32)
.onChange(of: geometry.frame(in: .global).minY) { newValue in
offsetY = newValue
}
}
)
}
}
}
.alert(isPresented: $isPresented) { Alert(title: Text("Button tapped")) }
}
}
}
struct HeaderView: View {
#Binding var isPresented: Bool
var body: some View {
VStack {
Image(systemName: "bell")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(Color(.systemBackground))
Button(action: { isPresented = false }) {
Text("Press")
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(16)
}
}
.padding()
}
}
struct MyButtonStyle: ButtonStyle {
let icon: Image
func makeBody(configuration: Configuration) -> some View {
Content(
configuration: configuration,
icon: icon
)
}
struct Content: View {
let configuration: Configuration
let icon: Image
var body: some View {
HStack(spacing: 18) {
Label(
title: { configuration.label },
icon: { icon.padding(.trailing, 8) }
)
Spacer()
Image(systemName: "chevron.right")
.accessibilityHidden(true)
}
.padding(18)
.foregroundColor(.white)
.background(Color.green)
.cornerRadius(8)
.opacity(configuration.isPressed ? 0.5 : 1) // <-- Comment this out and jumpiness goes away!!
}
}
}
Is there a performance improvement that can be done to use the opacity on the button press and make the jumpiness go away? Or a different way to approach this sticky offset because not sure if this is actually the source of the issue and I use opacity in a lot of places in my app (not just button presses)? The purpose of doing it this way is so the button can be tapped instead of putting it in the background of the scroll view. Thanks for any help or insight!
Related
I have a view in a ScrollView and base on an offset value. There is another view that needs to animate out.
This view is under the gray box but it places its base on a hardcoded value. How to pin the green box under the gray area without a hardcoded value? Using the hard coded value will not be consistent with other devices.
struct ContentView: View {
#State private var contentOffset = CGFloat(0)
var body: some View {
NavigationView {
ZStack {
VStack(spacing: 0) {
TrackableScrollView { offset in
contentOffset = offset.y
} content: {
VStack(spacing: 0) {
Text("Hello World")
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray)
}
}
}
Text("HELLO")
.frame(maxWidth: .infinity)
.background(Color.green)
.opacity(contentOffset < -16 ? 0 : 1)
.animation(.easeIn(duration: 0.2), value: contentOffset)
.offset(y: -280)
}
.ignoresSafeArea()
.frame(maxHeight: .infinity, alignment: .top)
.background(AccountBackground())
.navigationBarHidden(true)
}
}
}
What it looks like:
When you scroll up the green box will disappear.
To do this you need to an overlay to your ZStack. If you have a navigation bar the offset that you need to give it is 0 as it will be placed automatically below it. The TrackableScrollView is a preference key that is commonly found online to track the scrolling position of the scroll view.
struct ContentView: View {
#State private var contentOffset = CGFloat(0)
#State private var offsetPositionValue: CGFloat = 0
#State private var heightOfFrame: CGFloat = 0
#State private var isShyHeaderVisible = false
var body: some View {
NavigationView {
ZStack {
TrackableScrollView { offset in
withAnimation {
contentOffset = offset.y
}
} content: {
Text("Hello World")
}
.overlay(
ZStack {
HStack {
Text("Total number of points")
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text("20,000 pts")
.foregroundColor(.white)
.padding(.leading, 50)
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width)
.background(Color.green)
.offset(y: contentOffset < -16 ? 0 : heightOfFrame - 5)
.opacity(contentOffset < -16 ? 1 : 0)
.transition(.move(edge: .top))
}
.frame(maxHeight: .infinity, alignment: .top)
)
}
.navigationTitle("Hello")
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarTitleDisplayMode(.inline)
.frame(maxHeight: .infinity, alignment: .top)
.background(AccountBackground())
}
}
}
Get that strange glitch and can't fix it.
Source of problem - TabView and changing size of child views with animation.
Changing UITabbar appearance not helped. Changing safe area options not helped.
UITabBar.appearance().isHidden = false and opaque appearance give flickering of whole tabbar.
I want to hide default UITabbar to customize my own.
Any ideas?
Flickering of bottom safe area: Demonstration GIF
Sample project:
struct ContentView: View {
#State var selected = "second"
var body: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
FirstView()
TabView(selection: $selected) {
SecondView()
.tabItem({
Text("second")
})
.tag("second")
ThirdView()
.tabItem({
Text("third")
})
.tag("third")
}
}
HStack {
Image(systemName: selected == "second" ? "circle.fill" : "circle")
.onTapGesture {
selected = "second"
}
Image(systemName: selected == "third" ? "circle.fill" : "circle")
.onTapGesture {
selected = "third"
}
}
.frame(width: 70, height: 40, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.padding()
}
.edgesIgnoringSafeArea(.all)
.onAppear {
UITabBar.appearance().isHidden = true
}
}
}
struct FirstView: View {
#State var height:CGFloat = 200
var body: some View {
ZStack(alignment: .bottom) {
Color.red
.frame(height: height)
Image(systemName: height == 200 ? "arrow.down" : "arrow.up")
.foregroundColor(.white)
.padding(5)
.onTapGesture {
withAnimation(.easeInOut(duration: 2)) {
height = height == 200 ? 350 : 200
}
}
}
}
}
struct SecondView: View {
var body: some View {
ZStack {
Text("hello")
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(Color.green)
.onAppear {
UITabBar.appearance().isHidden = true
}
}
}
struct ThirdView: View {
var body: some View {
Color.blue
.edgesIgnoringSafeArea(.bottom)
}
}
Thanks for answers :)
Since you have your own index navigation anyways, I recommend to get rid of TabView altogether and switch views by if / else or switch statements:
struct ContentView: View {
#State var selected = "second"
var body: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
FirstView()
if selected == "second" {
SecondView()
} else {
ThirdView()
}
}
HStack {
Image(systemName: selected == "second" ? "circle.fill" : "circle")
.onTapGesture {
selected = "second"
}
Image(systemName: selected == "third" ? "circle.fill" : "circle")
.onTapGesture {
selected = "third"
}
}
.frame(width: 70, height: 40, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.padding()
}
.edgesIgnoringSafeArea(.all)
}
}
OUTLINE
I have made a custom slimline sidebar that I am now implementing across the whole app. The sidebar consists of a main button that is always showing and when pressed it shows or hides the rest of the sidebar that consists of buttons navigating to other views.
I am currently implementing the sidebar across the app on each view by creating a ZStack like this:
struct MainView: View {
var body: some View {
ZStack(alignment: .topLeading) {
SideBarCustom()
Text("Hello, World!")
}
}
}
PROBLEM
I am planning on adding a GeometryReader so if the side bar is shown the rest of the content moves over. With this in mind, the way I am implementing the sidebar on every view feels clunky and a long winded way to add it. Is there a more simple/better method to add this to each view?
Sidebar Code:
struct SideBarCustom: View {
#State var isToggle = false
var names = ["Home", "Products", "Compare", "AR", "Search"]
var icons = ["house.fill", "printer.fill.and.paper.fill", "list.bullet.rectangle", "arkit", "magnifyingglass"]
var imgSize = 20
var body: some View {
GeometryReader { geo in
VStack {
Button(action: {
self.isToggle.toggle()
}, label: {
Image("hexagons")
.resizable()
.frame(width: 40, height: 40)
.padding(.bottom, 20)
})
if isToggle {
ZStack{
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.red)
.frame(width: 70, height: geo.size.height)
VStack(alignment: .center, spacing: 60) {
ForEach(Array(zip(names, icons)), id: \.0) { item in
Button(action: {
// NAVIIGATE TO VIEW
}, label: {
VStack {
Image(systemName: item.1)
.resizable()
.frame(width: CGFloat(imgSize), height: CGFloat(imgSize))
Text(item.0)
}
})
}
}
}
}
}
}
}
}
I don't think there's necessarily a reason to use GeometryReader here. The following is an example that has a dynamic width sidebar (although you could set it to a fixed value) that slides in and out. The main content view resizes itself automatically, since it's in an HStack:
struct ContentView : View {
#State private var sidebarShown = false
var body: some View {
HStack {
if sidebarShown {
CustomSidebar(sidebarShown: $sidebarShown)
.frame(maxHeight: .infinity)
.border(Color.red)
.transition(sidebarShown ? .move(edge: .leading) : .move(edge: .trailing) )
}
ZStack(alignment: .topLeading) {
MainContentView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
if !sidebarShown {
Button(action: {
withAnimation {
sidebarShown.toggle()
}
}) {
Image(systemName: "info.circle")
}
}
}
}
}
}
struct CustomSidebar : View {
#Binding var sidebarShown : Bool
var body: some View {
VStack {
Button(action: {
withAnimation {
sidebarShown.toggle()
}
}) {
Image(systemName: "info.circle")
}
Spacer()
Text("Hi")
Text("There")
Text("World")
Spacer()
}
}
}
struct MainContentView: View {
var body: some View {
VStack {
Text("Main content")
}
}
}
I'm trying to build a view where the header is fixed at the top of the view and it changes it's size according to the scroll offset, when the offset is 0 the header is bigger and when the user scrolls the header becomes smaller
struct ContentView : View {
#State var largeHeader = true
var body: some View {
VStack {
Text("HEADER").padding(.vertical, largeHeader ? 30 : 10)
Divider()
ScrollView {
VStack {
Text("Content0")
.padding()
Text("Content1")
.padding()
Text("Content2")
.padding()
}
.background(GeometryReader { geometryProxy -> Color in
DispatchQueue.main.async {
largeHeader = geometryProxy.frame(in: .named("myspace")).minY >= 0
}
return Color.clear
})
}
.coordinateSpace(name: "myspace")
}.animation(.default)
}
}
It works fine when the scroll content is longer, but when there is a little content, as in the code above I get this flickering (It's even worse on the device, but the gif quality is low)
Any idea how to fix it?
Looks like there is some interference going on.
I have found two workaround-solutions, it depends on what your desired effect is.
Solution 1
Idea: Using ZStack so that ScrollView and your header don't interfere.
struct ContentView: View {
#State var largeHeader = true
var body: some View {
ZStack {
ScrollView {
VStack {
ForEach(0..<3) { i in
Text("Content\(i)")
.padding()
}
}
.background(GeometryReader { geometryProxy -> Color in
DispatchQueue.main.async {
largeHeader = geometryProxy.frame(in: .named("1")).minY >= 0
}
return Color.clear
})
}
.coordinateSpace(name: "1")
.offset(y: largeHeader ? 100 : 60)
VStack {
VStack {
Spacer()
Text("HEADER")
.padding()
Divider()
}
.frame(maxWidth: .infinity)
.frame(height: largeHeader ? 140 : 100)
.background(Color.white)
Spacer()
}
.edgesIgnoringSafeArea(.all)
}
.animation(.default)
}
}
The header of this one would always change the height, no matter how large the content-height is.
Solution 2
Idea: Only change header height when there is enough content to scroll.
Solution:
Finding out the height of the scrollview-content.
struct ContentView: View {
#State var largeHeader = true
#State var scrollViewScrollable = false
var body: some View {
VStack {
Text("HEADER").padding(.vertical, largeHeader ? 30 : 10)
Divider()
ScrollView {
VStack {
ForEach(0..<3) { i in
Text("Content\(i)")
.padding()
}
}
.background(GeometryReader { geometryProxy -> Color in
if scrollViewScrollable {
DispatchQueue.main.async {
largeHeader = geometryProxy.frame(in: .named("1")).minY >= 0
}
}
return Color.clear
})
.background(
GeometryReader { proxy in
Color.clear.onAppear {
scrollViewScrollable = proxy.frame(in: .named("1")).size.height >= UIScreen.main.bounds.size.height - 100
}
}
)
}
.coordinateSpace(name: "1")
}
.animation(.default)
}
}
I have a swift view that consists of a HStack with a rectangle and a Vstack of text inside. I want to make the height of the rectangle the same as the height of the Vstack. I have already tried looking through many other questions here on StackOverflow but didn't find an answer. Can anyone help me do that?
Here is my code:
struct TodoView: View {
#State var todos = ["feed the dog", "take the dog out for a walk", "make coffee"]
#State var height: CGFloat = 45
var body: some View {
HStack{
RoundedRectangle(cornerRadius: 2)
.frame(width: 1)
.foregroundColor(Color("lightGray"))
.padding()
VStack{
Text("Todo")
.font(.title)
ForEach(todos, id: \.self){ todo in
Text(todo)
}
}
Spacer()
}
}
}
You need to know the GeometryReader and PreferenceKey to make this possible.
struct SiblingHeightKey: PreferenceKey {
static var defaultValue: CGSize? {
nil
}
static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) {
value = value ?? nextValue()
}
}
struct TodoView: View {
#State var vStackSize: CGSize? = nil
#State var todos = ["feed the dog", "take the dog out for a walk", "make coffee"]
#State var height: CGFloat = 45
var body: some View {
HStack{
RoundedRectangle(cornerRadius: 2)
.foregroundColor(.gray)
.frame(width: self.vStackSize?.width, height: self.vStackSize?.height)
VStack{
Text("Todo")
.font(.title)
ForEach(todos, id: \.self){ todo in
Text(todo)
}
}.background(
GeometryReader { proxy in
Color.clear.preference(key: SiblingHeightKey.self, value: proxy.size)
}
)
Spacer()
}.onPreferenceChange(SiblingHeightKey.self) {
self.vStackSize = $0
}
}
}
You can use .frame modifier:
HStack{
RoundedRectangle(cornerRadius: 2)
.frame(width: 1, height: 50)
.foregroundColor(Color("lightGray"))
.padding()
VStack {
Text("Todo")
.font(.title)
ForEach(todos, id: \.self){ todo in
Text(todo)
}
.frame(height: 50)
}
Spacer()
}
If you want to have them fill the whole View:
.frame(minWidth: 0, maxWidth: .infinity)
Alternatively, you can use GeometryReader as proposed here: Make a grid of buttons of same width and height in SwiftUI.