2

I'm having trouble trying to center a single element to emulate the navigation modal with a close button. I would like to center content without using a supporting Rectangle on the sides or spacers.

What i'm trying to achieve is whenever the text grow, if it reaches the left sides where there is the close xmark button it should try to push itself on the right where there is available space until it reaches the right border and after wrap itself if there are no available space on the both sides.

here are some pictures:

expected result 1

expected result 1

expected result 2

expected result 2

current solution short text

current solution short text

current solution long text

current solution long text

i tried using long and short text to test the content behaviour

Currently this is the start of the code and basically i would like to avoid to add the blue rectangle (that would be usually clear)

   struct TestAlignmentSwiftUIView: View {
        var body: some View {
            HStack(spacing: 0) {
                Rectangle().fill(Color.blue).frame(width: 44, height: 44)
    
                Text("aaa eee aaa")
                    .background(Color.red)
                    .padding(5)
    
                Button(action: {}, label: {
                    Image(systemName: "xmark")
                        .padding(15)
                        .frame(width: 44, height: 44)
                        .background(Color.yellow)
                })
            }
            .background(Color.green)
        }
    }

What i've tried so far but doesn't resolve the issue if the code inside the text component grow:

  1. Using a zstack where i place the text and the close button one on top of each other but the button is pushed to the side using a spacer. It will work for small text or content but is not scalable if the text grows
    var body: some View {
        ZStack {
            HStack {
                Spacer()
                Button(action: {}, label: {
                    Image(systemName: "xmark")
                        .padding(15)
                        .frame(width: 44, height: 44)
                        .background(Color.yellow)
                })
            }
            Text("aaa eee aaa random long very long text that should wrap without overlapping. long text")
                .background(Color.red)
                .frame(maxWidth: .infinity, alignment: .center)
                .padding(5)
                .opacity(0.7)
        }
        .background(Color.green)
    }
  1. Using alignment guides : i would create my own center alignment guide, then use this custom alignment on a vstack where i place my content plus a fake filler rectangle that should center the elements on the content side.

the problem is that with swiftui , as far i know, you can only align one descendant element, and doesn't support multiple custom alignments on the stack of elements. so i would have only the text centered or the side button aligned not both aligned one to the center and the other to the trailing edge. and if i put a spacer between them it will just mess the alignment created. If I try with small text they will be both attached. Heres the code. try to comment the button and you will see that it will center itself or add spacer between them.

extension HorizontalAlignment {
    private enum MyAlignment: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            d[HorizontalAlignment.center]
        }
    }
    static let myAlignment = HorizontalAlignment(MyAlignment.self)
}
var body: some View {
        VStack(alignment: .myAlignment, spacing: 0) {
            HStack {
                Text("aaa eee aaa random  ")
                    .background(Color.red)
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(5)
                    .alignmentGuide(.myhAlignment, computeValue: { dimension in
                        dimension[HorizontalAlignment.center]
                    })

                Button(action: {}, label: {
                    Image(systemName: "xmark")
                        .padding(15)
                        .frame(width: 44, height: 44)
                        .background(Color.yellow)
                })
            }
            .background(Color.green)
            Rectangle()
                .fill(Color.purple)
                .frame(width: 10, height: 10, alignment: .center)

                .alignmentGuide(.myhAlignment, computeValue: { dimension in
                    dimension[HorizontalAlignment.center]
                })
        }
    }

  1. Tried with a combination of geometry reader and/or anchor preferences to read with sizes of the text content and side button width and apply the appropriate center offset manually, but it seems too hacky and it never worked as expected without good results

If you're familiar with uikit this problem would be resolved using a centerX on the container with a minor layout priority and a right constraint from the center to the close button, and call it a day. But on swiftui it seems soo hard to handle this simple cases.

So far i haven't found a solution without using a supporting fixed frame on the side that would work with both long and short text. that space is clearly visibile if you try to use long text. and it will leave the user to wonder why is not used.

¯\ (ツ)

EDIT: added possible solution in the answers

Gry
  • 101
  • 6
  • I just wish that I could ask my questions as a new contributor like you did here. Without any other users trying to correcting your question! I think it needs lots of luck. – ios coder Aug 05 '21 at 22:54
  • Have you tried to play with `.overlay` – cedricbahirwe Aug 06 '21 at 06:56
  • yep, it depends if with `.overlay` you mean using with a spacers on the sides and then it would be something like `Spacer() Text() Spacer().overlay(Button)` the problem with `.overlay` is that i lose information about the the button size. and if the text grow it will overlap. Don't know if you meant with '.overlay(Geometryreader { ... ) ' to get the size of the Button, but same problems of overlaps regardless. – Gry Aug 06 '21 at 08:06
  • You can add .hidden() to the blue rectangle or .opacity(0) then it would disappear while keeping the layout as is – Timmy Aug 06 '21 at 09:56
  • the blue rectangle is just to accentuate the problem, and show the current state for people to understand. usually i use a `.fill(Color.clear)`. The problem is, if you use a long text you will end with a lot of empty space on the right side where the blue rectangle is placed. but if it's hidden the user will see only blank space and the center text start to wrap on a new line on a strange offset from the margin. – Gry Aug 06 '21 at 12:52
  • 1
    I think you are on the right track using the filler view on the left. And I think shrinking the blue square when the text is ready to wrap is going to do what you want. @Asperi had [a solution to a similar problem](https://stackoverflow.com/a/62621521) with regard to TextFields that may give you what you need. Essentially you use another Text() and keep track of its size and then use that to set the size of the displayed Text(). – Yrb Aug 06 '21 at 14:25
  • thanks @Yrb i used your suggestion even if the linked solution is not what i used. the idea behind it, was useful. Honestly i did use this trick on other things but it didn't come in mind for this problem. – Gry Aug 08 '21 at 03:05

1 Answers1

0

From the @Yrb suggestion in the comments, here's what i came up that shrink the blue size so it will center on the available space. I added a fake text underneath and tracked the size. and if it's over the available space i will take the difference and shrink the blu rectangle. One thing to keep in mind is that the hidden content if contains some text should have linelimit 1, otherwise it will get a smaller size from wrapping itself.

And i just assume that i know the size of the close button (or at least one side) for center alignment, and even if i don't know it at compile time, i could probably use a preference key to get the size at run time, and have it dynamic.

But for the moment i think it's fine the result that i got. but honestly i hope to find something more easier in the future.

@State var text: String = "aaa eee aaa"
@State private var fillerWidth: CGFloat = 44
// i assume i know the max size of the close button or at least one side
private let kCloseButtonWidth: CGFloat = 44 

private struct FakeSizeTitlteContentKey: PreferenceKey {
    static var defaultValue: CGFloat { .zero }
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

var body: some View {
    ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
        GeometryReader { parentGeometry in
            titleContent
                .lineLimit(1) // hidden text must not wrap
                .overlay(GeometryReader { proxyFake in
                    Color.clear.border(Color.black, width: 0.3)
                        .preference(key: FakeSizeTitlteContentKey.self, value: proxyFake.frame(in: .local).width
                        .onPreferenceChange(FakeSizeTitlteContentKey.self) { value in
                            let availableW = parentGeometry.frame(in: .local).width
                            let fillSpace = availableW - value - kCloseButtonWidth * 2
                            fillerWidth = min(kCloseButtonWidth, max(0, fillSpace))
                        }
                })
        }
        .hidden()
        VStack {
            HStack(spacing: 0) {
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: fillerWidth, height: 44)
                titleContent
                    .background(Color.green)
                    .multilineTextAlignment(.center)
                    .frame(maxWidth: .infinity, alignment: .center)
                Button(action: {}, label: {
                    Image(systemName: "xmark")
                        .padding(15)
                        .frame(width: kCloseButtonWidth, height: kCloseButtonWidth)
                        .background(Color.yellow)
                })
            }
            .coordinateSpace(name: "fullCont")
            .background(Color.green)
            
            TextEditor(text: $text)
                .frame(maxHeight: 150, alignment: .center)
                .border(Color.black, width: 1)
                .padding(15)
            Spacer()
        }
    }
}

@ViewBuilder var titleContent: some View {
    HStack(spacing: 0) {
        Text(text)
            .background(Color.red)
            .padding(.horizontal, 5)
    }
}
Gry
  • 101
  • 6