Align Views by constraints - ios

I would like to build a view similar to this
✓ **Title Text**
Description Text
Where the icon an the title text have the same horizontal center ant the title text and the description text have the same alginment at the left.
Since i could not find any possiblity in SwiftUI to set constraints I am a little bit stuck.
The best solution i could come up was this
HStack(alignment: .top, spacing: Constants.Stacks.defaultHorizontalSpacing) {
challengeTask.status.getIconImage()
VStack(alignment: .leading, spacing: Constants.Stacks.defaultVerticalSpacing) {
Text(challengeTask.title)
.titleText()
Text(challengeTask.description)
.multilineTextAlignment(.leading)
.descriptionText()
Spacer()
}
}
But this does not align the icon horiztontally with the title text

iOS 16
struct ContentView: View {
var body: some View {
HStack(alignment: .firstTextBaseline) {
// ^^^^^^^^^^^^^^^^^
Image(systemName: "checkmark")
VStack {
Text("Title").font(.title.bold())
Text("Content").multilineTextAlignment(.leading)
}
}
}
}
Using hidden
struct ContentView: View {
var body: some View {
VStack {
HStack {
iconImage()
Text("Title").font(.title.bold())
}
HStack {
iconImage().hidden()
Text("Content").multilineTextAlignment(.leading)
}
}
}
private func iconImage() -> some View {
// Change it to your image
Image(systemName: "checkmark")
}
}
Using GeometryReader
struct ContentView: View {
#State private var iconSize: CGFloat = .zero
var body: some View {
VStack {
HStack(spacing: 10) {
Image(systemName: "checkmark")
.readSize { iconSize = $0.width }
Text("Title")
.font(.title.bold())
}
Text("Content")
.padding(.leading, iconSize + 10)
}
}
}
fileprivate struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
modifier(ReadSize(onChange))
}
}
fileprivate struct ReadSize: ViewModifier {
let onChange: (CGSize) -> Void
init(_ onChange: #escaping (CGSize) -> Void) {
self.onChange = onChange
}
func body(content: Content) -> some View {
content.background(
GeometryReader { geometryProxy in
Color.clear.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
}
Hope this help!

I think the right way to deal with this is using a custom alignment as described here.
In your case you have 2 possibilities depending on how you want to implement the UI hierarchy:
Align image center with title center
Align description leading with title leading
Playground for both versions:
import SwiftUI
import PlaygroundSupport
extension HorizontalAlignment {
private struct TitleAndDescriptionAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.center]
}
}
static let titleAndDescriptionAlignmentGuide = HorizontalAlignment(
TitleAndDescriptionAlignment.self
)
}
extension VerticalAlignment {
private struct ImageAndTitleCenterAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[VerticalAlignment.center]
}
}
static let imageAndTitleCenterAlignmentGuide = VerticalAlignment(
ImageAndTitleCenterAlignment.self
)
}
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
VStack(alignment: .titleAndDescriptionAlignmentGuide) {
HStack {
Image(systemName: "rosette")
Text("Title text")
.font(.largeTitle)
.alignmentGuide(.titleAndDescriptionAlignmentGuide) { context in
context[.leading]
}
}
Text("Description text")
.alignmentGuide(.titleAndDescriptionAlignmentGuide) { context in
context[.leading]
}
}
HStack(alignment: .imageAndTitleCenterAlignmentGuide) {
Image(systemName: "rosette")
.alignmentGuide(.imageAndTitleCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
VStack(alignment: .leading) {
Text("Title text")
.font(.largeTitle)
.alignmentGuide(.imageAndTitleCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
Text("Description text")
}
}
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())

Related

How to fit horizontal ScrollView to content's height?

I have a horizontal scroll view with a LazyHStack. How do I size the scroll view to automatically fit the content inside?
By default, ScrollView takes up all the vertical space possible.
struct ContentView: View {
var numbers = 1...100
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(numbers, id: \.self) {
Text("\($0)")
.font(.largeTitle)
}
}
}
}
}
This code can be MUCH better
I have show it as BASE for your final solution
But it works
struct ContentView1111: View {
var numbers = 1...100
var body: some View {
VStack {
FittedScrollView(){
AnyView(
LazyHStack {
ForEach(numbers, id: \.self) {
Text("\($0)")
.font(.largeTitle)
}
}
)
}
// you can see it on screenshot - scrollView size
.background(Color.purple)
Spacer()
}
// you can see it on screenshot - other background
.background(Color.green)
}
}
struct HeightPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat = 40
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct FittedScrollView: View {
var content: () -> AnyView
#State private var contentHeight: CGFloat = 40
var body: some View {
VStack {
ScrollView(.horizontal) {
content()
.overlay(
GeometryReader { geo in
Color.clear
.preference(key: HeightPreferenceKey.self, value: geo.size.height)
})
}
.frame(height: contentHeight)
}
.onPreferenceChange(HeightPreferenceKey.self) {
contentHeight = $0
}
}
}

Choppy scrolling with ScrollView when updating View while scrolling SwiftUI

I am using my own ScrollView which has scroll start and end callbacks, so that I can perform some actions based on them, like hiding/showing a banner.
Here's my code for ScrollView
struct TrackableScrollView<Content>: View where Content: View {
private let onScrollingStarted: () -> Void
private let onScrollingFinished: () -> Void
#State var scrollViewHelper = ScrollViewHelper()
let content: Content
public init(#ViewBuilder content: () -> Content,
onScrollingStarted: #escaping () -> Void = {},
onScrollingFinished: #escaping () -> Void = {}) {
self.content = content()
self.onScrollingStarted = onScrollingStarted
self.onScrollingFinished = onScrollingFinished
}
public var body: some View {
GeometryReader { outsideProxy in
ScrollView(.vertical, showsIndicators: true) {
ZStack(alignment: .top) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
}
self.content
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollViewHelper.currentOffset = value[0]
}
.simultaneousGesture(
DragGesture().onChanged { _ in
onScrollingStarted()
}
)
.onReceive(scrollViewHelper.$offsetAtScrollEnd) { _ in
onScrollingFinished()
}
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
class ScrollViewHelper: ObservableObject {
#Published var currentOffset: CGFloat = 0
#Published var offsetAtScrollEnd: CGFloat = 0
private var cancellable: AnyCancellable?
init() {
cancellable = AnyCancellable($currentOffset
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.dropFirst()
.assign(to: \.offsetAtScrollEnd, on: self))
}
}
And here's my code to show some content
struct ContentView: View {
#State var messageBannerVisisbility: Bool = false
var body: some View {
VStack {
TrackableScrollView {
VStack(alignment: .center, spacing: 0) {
ForEach(0...100, id: \.self) { i in
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.green)
.overlay(Text("\(i)"))
.padding()
}
}
} onScrollingStarted: {
hideMessageBanner()
} onScrollingFinished: {
showMessageBanner()
}
if messageBannerVisisbility {
Rectangle()
.frame(height: 100)
.foregroundColor(.red)
.overlay(Text("Random bottom view"))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationBarHidden(true)
.onAppear {
showMessageBanner()
}
}
}
I am toggling messageBannerVisisbility with animation inside showMessageBanner() function.
If I keep below code, scrolling is not smooth. Could it be because I am showing/hiding the banner with animation on scroll callbacks? I guess I can just update the banner, instead of the whole View, but I am not sure how can I achieve that!
if messageBannerVisisbility {
Rectangle()
.frame(height: 100)
.foregroundColor(.red)
.overlay(Text("Random bottom view"))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
What could I do to improve scrolling experience? My app does support iOS 13, but I am also fine with implementing 2 different solutions, one for iOS 13 and other one for iOS 14 and above, if that makes life a little easier!

SwiftUI show custom sheet over tab bar

I have custom tab bar with two screen and I created a modifier to display custom sheet from bottom.
I don't want to use .sheet cause I can't change cornerRadius and also I want my sheet to be self-sized (dynamique height).
MainView:
struct MainView: View {
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentTab) {
Screen1()
.tag(Tab.screen1)
Screen2()
.tag(Tab.screen2)
}
.tabViewStyle(DefaultTabViewStyle())
HStack {
TabItemView(currentTab: $currentTab, tab: .screen1)
TabItemView(currentTab: $currentTab, tab: .screen2)
}
}
}
struct TabItemView: View {
#Binding var currentTab: Tab
var tab: Tab
var body: some View {
Button {
currentTab = tab
} label: {
VStack {
Image(tab.image)
.resizable()
.frame(width: 30, height: 30)
.frame(maxWidth: .infinity)
Text(tab.rawValue)
}.padding(.vertical, 10)
}
}
}
Custom BottomSheet :
struct BottomSheet<Content: View>: View {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let content: Content
init(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, content: #escaping () -> Content) {
self._isPresented = isPresented
self.onDismiss = onDismiss
self.content = content()
}
var isiPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
public var body: some View {
ZStack {
if isPresented {
Color.black.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.transition(.opacity)
.zIndex(1)
.onTapGesture {
dismiss()
}
container
.ignoresSafeArea(.container, edges: .bottom)
.transition(isiPad ? AnyTransition.opacity.combined(with: .offset(x: 0, y: 200)) : .move(edge: .bottom))
.zIndex(2)
}
}.animation(.spring(response: 0.35, dampingFraction: 1), value: isPresented)
}
private var container: some View {
VStack {
Spacer()
if isiPad {
card.aspectRatio(1.0, contentMode: .fit)
Spacer()
} else {
card
}
}
}
private var card: some View {
VStack(alignment: .trailing, spacing: 0) {
content
}
.background(Color.background)
.clipShape(RoundedRectangle(cornerRadius: .radius_xl))
}
func dismiss() {
withAnimation {
isPresented = false
}
onDismiss?()
}
}
extension View {
func bottomSheet<Content: View>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, backgroundColor: Color = Color(.systemGray6), #ViewBuilder content: #escaping () -> Content) -> some View {
ZStack {
self
BottomSheet(isPresented: isPresented, onDismiss: onDismiss) { content() }
}
}
}
Screen 1:
struct Screen1: View {
...
#State var isPresented = false
...
var body: some View {
Button(action: {
isPresented = true
}, label: {
HStack {
Spacer()
Image(systemName: "plus")
Text("Add")
Spacer()
}
})
.bottomSheet(isPresented: $isPresented) {
PopupView(isPresented: $isPresented, message: "") {
//Action when user confirm
}
}
}
}
The problem is bottom sheet is presented behind my tab bar, when I move my .bottomSheet modifier to MainView it's working perfectly but in my case I want to use .bottomSheet in child views cause I have actions to do when user tap confirm or cancel button.
PS: I want to mimic .sheet behavior, always presenting view on top of root

How to make SwiftUI Grid lay out evenly based on width?

I'm trying to use a SwiftUI Lazy Grid to lay out views with strings of varying lengths. How can I construct my code so that, e.g. if 3 view's do not fit, it will only make 2 columns and push the 3rd view to the next row so that they won't overlap?
struct ContentView: View {
var data = [
"Beatles",
"Pearl Jam",
"REM",
"Guns n Roses",
"Red Hot Chili Peppers",
"No Doubt",
"Nirvana",
"Tom Petty and the Heart Breakers",
"The Eagles"
]
var columns: [GridItem] = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
LazyVGrid(columns: columns, alignment: .center) {
ForEach(data, id: \.self) { bandName in
Text(bandName)
.fixedSize(horizontal: true, vertical: false)
}
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can use this method to achieve what you're looking for, solution source: https://www.fivestars.blog/articles/flexible-swiftui/
ContentView
struct ContentView: View {
// MARK: - PROPERTIES
var data = [
"Beatles",
"Pearl Jam",
"REM",
"Guns n Roses",
"Red Hot Chili Peppers",
"No Doubt",
"Nirvana",
"Tom Petty and the Heart Breakers",
"The Eagles"
]
// MARK: - BODY
var body: some View {
FlexibleView(
availableWidth: UIScreen.main.bounds.width, data: data,
spacing: 15,
alignment: .leading
) { item in
Text(verbatim: item)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
)
}
.padding(.horizontal, 10)
}
}
// MARK: - PREVIEW
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
FlexibleView
// MARK: - FLEXIBLE VIEW
struct FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let availableWidth: CGFloat
let data: Data
let spacing: CGFloat
let alignment: HorizontalAlignment
let content: (Data.Element) -> Content
#State var elementsSize: [Data.Element: CGSize] = [:]
var body : some View {
VStack(alignment: alignment, spacing: spacing) {
ForEach(computeRows(), id: \.self) { rowElements in
HStack(spacing: spacing) {
ForEach(rowElements, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
}
func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth
for element in data {
let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]
if remainingWidth - (elementSize.width + spacing) >= 0 {
rows[currentRow].append(element)
} else {
currentRow = currentRow + 1
rows.append([element])
remainingWidth = availableWidth
}
remainingWidth = remainingWidth - (elementSize.width + spacing)
}
return rows
}
}
View Extension
// MARK: - EXTENSION
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)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

SwiftUI: Check if text fit in parent view, otherwise remove that

I need implement next flow in my code:
Show text(1 line) and if it is cut or clipped (not fully visible) I should remove them,
I don't want scale text or make it in 2 lines
so I found solution, this solution is compose from few different topics:
struct TruncatableText: View {
let text: (() -> Text)
let lineLimit: Int?
#State private var intrinsicSize: CGSize = .zero
#State private var truncatedSize: CGSize = .zero
#State private var hide: Bool = false
var body: some View {
text()
.lineLimit(lineLimit)
.readSize { size in
truncatedSize = size
hide = truncatedSize != intrinsicSize
}
.background(
text()
.fixedSize(horizontal: false, vertical: true)
.hidden()
.readSize { size in
intrinsicSize = size
hide = truncatedSize != intrinsicSize
}
)
.isShow(!hide)
}
}
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)
}
#ViewBuilder func isShow(_ show: Bool) -> some View {
if show {
self
} else {
self.hidden()
}
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
and using it:
TruncatableText(
text: {
Text("title")
.font(.system(size: 21, weight: .semibold))
.foregroundColor(Color.blue)
},
lineLimit: 1
)

Resources