SwiftUI HStack elements with equal height - ios

I want both the buttons to have equal height similar to Equal Height constraint in UIKit.
Don't want to specify the frame, let SwiftUI handle it but the elements in HStack should be of the same height.
Buttons should have equal width and height and adapt to longer text and increases their frame size
Both the buttons should display their complete text (Font to scale / Fit shouldn't be used)
Sample Code
struct SampleView: View {
var body: some View {
GeometryReader { gr in
VStack {
ScrollView {
VStack {
// Fills whatever space is left
Rectangle()
.foregroundColor(.clear)
Image(systemName: "applelogo")
.resizable()
.frame(width: gr.size.width * 0.5, height: gr.size.height * 0.3, alignment: .center)
//.border(Color.blue)
.padding(.bottom, gr.size.height * 0.06)
Text("SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME")
.fontWeight(.regular)
.foregroundColor(.green)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
.layoutPriority(1)
// Fills 15 %
Rectangle()
.frame(height: gr.size.height * 0.12)
.foregroundColor(.clear)
DynamicallyScalingView()
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
// Makes the content stretch to fill the whole scroll view, but won't be limited (it can grow beyond if needed)
.frame(minHeight: gr.size.height)
}
}
}
}
}
struct DynamicallyScalingView: View {
#State private var labelHeight = CGFloat.zero // << here !!
var body: some View {
HStack {
Button(action: {
}, label: {
Text("Button 1")
})
.foregroundColor(Color.white)
.padding(.vertical)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: labelHeight)
.background(Color.blue)
.cornerRadius(8)
Button(action: {
}, label: {
Text("Larger Button 2 Text Text2")
})
.foregroundColor(Color.white)
.padding(.vertical)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(8)
.background(GeometryReader { // << set right side height
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
}
.onPreferenceChange(ViewHeightKey.self) { // << read right side height
self.labelHeight = $0 // << here !!
}
.padding(.horizontal)
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
struct SampleView_Previews: PreviewProvider {
static var previews: some View {
SampleView().previewDevice("iPhone SE (2nd generation)")
}
}

You can set the max value in the ViewHeightKey preference key:
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = max(value, nextValue()) // set the `max` value (from both buttons)
}
}
and then read view height from both buttons and force vertical fixedSize:
struct DynamicallyScalingView: View {
#State private var labelHeight = CGFloat.zero
var body: some View {
HStack {
Button(action: {}, label: {
Text("SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME TEXT SOME")
})
.foregroundColor(Color.white)
.padding(.vertical)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: labelHeight) // min height for both buttons
.background(Color.blue)
.cornerRadius(8)
.fixedSize(horizontal: false, vertical: true) // expand vertically
.background(GeometryReader { // apply to both buttons
Color.clear
.preference(
key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height
)
})
Button(action: {}, label: {
Text("jahlsd")
})
.foregroundColor(Color.white)
.padding(.vertical)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: labelHeight)
.background(Color.blue)
.cornerRadius(8)
.fixedSize(horizontal: false, vertical: true)
.background(GeometryReader {
Color.clear
.preference(
key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height
)
})
}
.onPreferenceChange(ViewHeightKey.self) {
self.labelHeight = $0
}
.padding(.horizontal)
}
}
Note: as the buttons are similar now, the next step would be to extract them as another component to avoid duplication.

Related

SwiftUI TextEditor Divider doesn't change Y position based on text-line count?

I am trying to make a SwiftUI TextEditor with a Divider that adapts its position to stay under the bottom-most line of text inside of a edit-bio section of the app.
Note: I have a frame on my TextEditor so that it doesn't take up the whole-screen
Right now the Divider is static and stays in one place. Is there a built-in way to make the divider stay under the bottom most line of text?
I would think the Spacer would have given me this behavior?
Thank you!
struct EditBio: View {
#ObservedObject var editProfileVM: EditProfileViewModel
var body: some View {
VStack(spacing: 10) {
TextEditor(text: $editProfileVM.bio)
.foregroundColor(.white)
.padding(.top, 70)
.padding([.leading, .trailing], 50)
.frame(minWidth: 100, idealWidth: 200, maxWidth: 400, maxHeight: 200, alignment: .center)
Divider().frame(height: 1).background(.white)
Spacer()
}
}
}
It is doing exactly what you told it to do. But a background color on your TextEditor. You will see that it has a height of 200 + a spacing of 10 from the VStack.
I changed your code to make it obvious:
struct EditBio: View {
#State var editProfileVM = ""
var body: some View {
VStack(spacing: 10) {
TextEditor(text: $editProfileVM)
.foregroundColor(.white)
.padding(.top, 70)
.padding([.leading, .trailing], 50)
.frame(minWidth: 100, idealWidth: 200, maxWidth: 400, maxHeight: 200, alignment: .center)
.background(Color.gray)
Divider().frame(height: 1).background(.red)
Spacer()
}
}
}
to produce this:
You can see the TextEditor naturally wants to be taller than 200, but that is limiting it. Therefore, the Spacer() is not going to cause the TextEditor to be any smaller.
The other problem that setting a fixed frame causes will be that your text will end up off screen at some point. I am presuming what you really want is a self sizing TextEditor that is no larger than it's contents.
That can be simply done with the following code:
struct EditBio: View {
#State var editProfileVM = ""
var body: some View {
VStack(spacing: 10) {
SelfSizingTextEditor(text: $editProfileVM)
// Frame removed for the image below.
// .frame(minWidth: 100, idealWidth: 200, maxWidth: 400, maxHeight: 200, alignment: .center)
.foregroundColor(.white)
// made the .top padding to be .vertical
.padding(.vertical, 70)
.padding([.leading, .trailing], 50)
.background(Color.gray)
Divider().frame(height: 5).background(.red)
Spacer()
}
}
}
struct SelfSizingTextEditor: View {
#Binding var text: String
#State var textEditorSize = CGSize.zero
var body: some View {
ZStack {
Text(text)
.foregroundColor(.clear)
.copySize(to: $textEditorSize)
TextEditor(text: $text)
.frame(height: textEditorSize.height)
}
}
}
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
func copySize(to binding: Binding<CGSize>) -> some View {
self.readSize { size in
binding.wrappedValue = size
}
}
}
producing this view:

Custom collapsible view

I'm trying to create a custom collapsible view. The code works but in Collapsible<Content: View> the VStack has strange behavior: the elements overlap when the component is closed.
To note this, try disable clipped() as shown in the image.
Is it a bug or something so stupid that I am not noticing?
Thanks in advance
FIXED CODE:
struct Collapsible<Content: View>: View {
var label: String
var content: () -> Content
init(label: String, #ViewBuilder _ content: #escaping () -> Content) {
self.label = label
self.content = content
}
#State private var collapsed: Bool = true
var body: some View {
VStack(spacing: 0) {
Button(action: {
withAnimation(.easeInOut) {
self.collapsed.toggle()
}
}, label: {
HStack {
Text(label)
Spacer(minLength: 0)
Image(systemName: self.collapsed ? "chevron.down" : "chevron.up")
}
.padding()
.background(Color.white.opacity(0.1))
}
)
.buttonStyle(PlainButtonStyle())
self.content()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: collapsed ? 0 : .none, alignment: .top) // <- added `alignment` here
.clipped() // Comment to see the overlap
.animation(.easeOut)
.transition(.slide)
}
}
}
struct CollapsibleDemoView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Collapsible(label: "Collapsible") {
Text("Content")
.padding()
.background(Color.red)
}
Spacer(minLength: 0)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
The .frame modifier has a parameter alignment which defaults to center which results in the behaviour you're seeing: the layout bounds are set to zero height but the content is rendered vertically centered beyond the bounds (if not clipped). You can fix this by adding the alignment:
.frame(maxHeight: 0, alignment: .top)

Jumpy performance issue when offset and opacity used

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!

SwiftUI: PreferenceKey is not called when parent view is embedded in NavigationView

DynamicScalingView is a child view with two buttons designed to have equal width & height using preferencekey
Issue: When DynamicScalingView is embedded in NavigationView it not longer adapts to intrinsic and increases its frame size. Current implementation without Navigation works fine but would like to understand how to fix this issue when embedded in NavigationView
Uncomment NavigationView in sample view to reproduce the issue
DynamicScalingView should adapt to Dynamic font size and increase its frame size maintaining equal width & height constraint between the buttons.
XCode 12.2 and iOS 14.2
struct SampleView: View {
var body: some View {
GeometryReader { gr in
//NavigationView {
ScrollView {
VStack {
// fills whatever space is left
Spacer()
.foregroundColor(.clear)
// view should fit with intrinsic content size
DynamicScalingView()
.padding(.horizontal, 20)
.border(Color.blue, width: 1)
}
.padding(.bottom, 20)
.border(Color.red, width: 1)
.frame(minHeight: gr.size.height)
.navigationBarHidden(true)
}
// }
}
}
struct DynamicScalingView: View {
#State private var labelHeight = CGFloat.zero
var body: some View {
HStack {
Button(action: {}, label: {
Text("Some Text Some Text Some Text")
.padding(.horizontal, 2)
})
.foregroundColor(Color.white)
.padding(.vertical)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: labelHeight)
.background(Color.blue)
.cornerRadius(8)
.fixedSize(horizontal: false, vertical: true)
.background(GeometryReader {
Color.clear
.preference(
key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height
)
})
Button(action: {}, label: {
Text("Some Text")
.padding(.horizontal, 2)
})
.foregroundColor(Color.white)
.padding(.vertical)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minHeight: labelHeight)
.background(Color.blue)
.cornerRadius(8)
.fixedSize(horizontal: false, vertical: true)
.background(GeometryReader {
Color.clear
.preference(
key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height
)
})
}
.onPreferenceChange(ViewHeightKey.self) {
self.labelHeight = $0
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = max(value, nextValue())
}
}
}

Automatically adjustable view height based on text height in SwiftUI

I am trying to create a view in SwiftUI where the background of the image on the left should scale vertically based on the height of the text on the right.
I tried a lot of different approaches, from GeometryReader to .layoutPriority(), but I haven't managed to get any of them to work.
Current state:
Desired state:
I know that I could imitate the functionality by hardcoding the .frame(100) for the example I posted, but as text on the right is dynamic, that wouldn't work.
This is full code for the view in the screenshot:
import SwiftUI
struct DynamicallyScalingView: View {
var body: some View {
HStack(spacing: 20) {
Image(systemName: "snow")
.font(.system(size: 32))
.padding(20)
.background(Color.red.opacity(0.4))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 8) {
Text("My Title")
.foregroundColor(.white)
.font(.system(size: 13))
.padding(5)
.background(Color.black)
.cornerRadius(8)
Text("Dynamic text that can be of different leghts. Spanning from one to multiple lines. When it's multiple lines, the background on the left should scale vertically")
.font(.system(size: 13))
}
}
.padding(.horizontal)
}
}
struct DailyFactView_Previews: PreviewProvider {
static var previews: some View {
DynamicallyScalingView()
}
}
Here is a solution based on view preference key. Tested with Xcode 11.4 / iOS 13.4
struct DynamicallyScalingView: View {
#State private var labelHeight = CGFloat.zero // << here !!
var body: some View {
HStack(spacing: 20) {
Image(systemName: "snow")
.font(.system(size: 32))
.padding(20)
.frame(minHeight: labelHeight) // << here !!
.background(Color.red.opacity(0.4))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 8) {
Text("My Title")
.foregroundColor(.white)
.font(.system(size: 13))
.padding(5)
.background(Color.black)
.cornerRadius(8)
Text("Dynamic text that can be of different leghts. Spanning from one to multiple lines. When it's multiple lines, the background on the left should scale vertically")
.font(.system(size: 13))
}
.background(GeometryReader { // << set right side height
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
}
.onPreferenceChange(ViewHeightKey.self) { // << read right side height
self.labelHeight = $0 // << here !!
}
.padding(.horizontal)
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
This is the answer without workaround.
struct DynamicallyScalingView: View {
var body: some View {
HStack(spacing: 20) {
Image(systemName: "snow")
.frame(maxHeight: .infinity) // Add this
.font(.system(size: 32))
.padding(20)
.background(Color.red.opacity(0.4))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 8) {
Text("My Title")
.foregroundColor(.white)
.font(.system(size: 13))
.padding(5)
.background(Color.black)
.cornerRadius(8)
Text("Dynamic text that can be of different leghts. Spanning from one to multiple lines. When it's multiple lines, the background on the left should scale vertically")
.font(.system(size: 13))
}
.frame(maxHeight: .infinity) // Add this
}
.padding(.horizontal)
.fixedSize(horizontal: false, vertical: true) // Add this
}
}

Resources