Fading Text ViewModifier to simulate truncationMode .byWordWrapping and apply fading mask - swift

I am trying to create a ViewModifier called .fade that can be used on SwiftUI.Text and simulates a fading effect with a truncation mode of .byWordWrapping known from UIKit. In addition I apply a fading mask to the last line of the Text View.
In my example code for a static View everything works as expected, but for some dynamic changes the modifier creates a wrong layout or does not update properly.
My actual problems are:
changing the lineLimit dynamically
Everything works if going from a larger lineLimit to a smaller one, but the other way going from smaller to larger values is not working
Changing the size of the parent Stack does not resize the view if the view is already faded. Meaning the view exceeds the parent bounds of width: 300 (Can be simulated in the TestView by showing the image while a text is faded.)
Helper ViewModifer to determine the size of a view:
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
public 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)
}
}
The FadingViewModifer itself:
private struct FadingTextViewModifier: ViewModifier {
#State private var intrinsicSize = CGSize.zero
#State private var truncatedSize = CGSize.zero
#State private var isTruncated = false
let lineLimit: Int?
let font: UIFont
let useMaxWidth: Bool
let alignment: Alignment
let isTruncatedAction: ((Bool) -> Void)?
var maxWidth: CGFloat? { useMaxWidth ? .infinity : nil }
func body(content: Content) -> some View {
let truncatedView = content
.font(Font(font))
.lineLimit(lineLimit)
.readSize { size in
truncatedSize = size
isTruncated = truncatedSize != intrinsicSize
isTruncatedAction?(isTruncated)
}
let intrinsicView = content
.font(Font(font))
.fixedSize(horizontal: false, vertical: true)
.readSize { size in
intrinsicSize = size
isTruncated = truncatedSize != intrinsicSize
isTruncatedAction?(isTruncated)
}
let view = content
.font(Font(font))
.frame(maxWidth: maxWidth, alignment: alignment)
.background(truncatedView.hidden(), alignment: alignment)
.background(intrinsicView.hidden(), alignment: alignment)
Group {
if isTruncated {
content
.font(Font(font))
.fixedSize(horizontal: lineLimit == 1, vertical: true)
.frame(width: truncatedSize.width, height: truncatedSize.height, alignment: alignment)
.frame(maxWidth: maxWidth, alignment: alignment)
.clipped()
.mask(maskView)
.background(Color.yellow)
.background(view.hidden())
} else {
view
}
}
.background(Color.purple)
}
}
Extension to create the maskView that is used for the fading:
private extension FadingTextViewModifier {
var ellipsisWidth: CGFloat { lineLimit == 1 ? 0 : 15 }
var fadeOutWidth: CGFloat { 50 }
var maskStart: UnitPoint {
let width = truncatedSize.width
let visibleWidth = width - ellipsisWidth - fadeOutWidth
let startX: CGFloat = width > 0 && visibleWidth > 0 ? visibleWidth / width : 0.8
return .init(x: startX, y: 0.5)
}
var maskEnd: UnitPoint {
let width = truncatedSize.width
let endX: CGFloat = width > 0 && width > ellipsisWidth ? (width - ellipsisWidth) / width : 1
return .init(x: endX, y: 0.5)
}
// Only apllied to the last line.
var maskView: some View {
VStack(spacing: 0) {
Rectangle()
LinearGradient(colors: [.white, .clear], startPoint: maskStart, endPoint: maskEnd)
.frame(height: font.lineHeight)
}
}
}
The actual ViewModifier extension:
extension Text {
/// Fades the `Text` if needed.
/// - Parameters:
/// - lineLimit: Defines the lineLimit of your text. Modifies by default the lineLimit to 1 instead of nil
/// - font: Needs an UIFont to determine the lineHeight for the masking effect. Directly applies the font to the Text.
/// - useMaxWidth: Only relevant for lineLimit of 1. By default the text view uses maxWidth .infinity to avoid a fading effect, although it would look like space is available.
/// - alignment: Specifies the text alignment inside the frame
/// - isTruncatedAction: Returns if the text is truncated or not. Use this to customize your view. e.g. a "show more"-button
func fade(lineLimit: Int? = 1,
font: UIFont = UIFont.systemFont(ofSize: 16),
useMaxWidth: Bool = true,
alignment: Alignment = .topLeading,
isTruncatedAction: ((Bool) -> Void)? = nil) -> some View {
modifier(FadingTextViewModifier(lineLimit: lineLimit,
font: font,
useMaxWidth: useMaxWidth,
alignment: alignment,
isTruncatedAction: isTruncatedAction))
}
}
A testView to simulate the problems:
struct FadingTextTest: View {
#State var withImage = false
#State var useLongText = false
#State var lineLimit = 1
let shortText = "This is a short text!"
let longText = "This is a much much much much much much much much much much longer text!"
var text: String {
useLongText ? longText : shortText
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Button("activate image") { withImage.toggle() }
Button("use long text") { useLongText.toggle() }
Stepper("Line Limit \(lineLimit)", value: $lineLimit)
.padding(.bottom, 50)
// Single Fading Text that does not need to adapt to size changes of the parent dynamically
Text(text)
.fade(lineLimit: lineLimit)
// Text that needs to adapt dynamically to width changes inside a Stack
HStack(spacing: 0) {
Text(text)
.fade(lineLimit: lineLimit)
if withImage {
Image(systemName: "star")
.resizable()
.frame(width: 20, height: 20)
}
}
// Text that fades at intrinsic width
Text(text)
.fade(lineLimit: lineLimit, useMaxWidth: false)
// Text without fading, but with lineLimit
Text(text)
.font(Font(UIFont.systemFont(ofSize: 16)))
.lineLimit(lineLimit)
.frame(maxWidth: .infinity, alignment: .topLeading)
// Text without any effect
Text(text)
.font(Font(UIFont.systemFont(ofSize: 16)))
.frame(maxWidth: .infinity, alignment: .topLeading)
Spacer()
}
.frame(maxHeight: .infinity)
.background(Color.green)
.frame(width: 300)
}
}

Related

SwiftUI - Dynamic LazyHGrid row height

I'm creating vertical layout which has scrollable horizontal LazyHGrid in it. The problem is that views in LazyHGrid can have different heights (primarly because of dynamic text lines) but the grid always calculates height of itself based on first element in grid:
What I want is changing size of that light red rectangle based on visible items, so when there are smaller items visible it should look like this:
and when there are bigger items it should look like this:
This is code which results in state on the first image:
struct TestView: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem()],
alignment: .top,
spacing: 16
) {
Color.red
.frame(width: 64, height: 24)
ForEach(Array(0...10), id: \.self) { value in
Color.red
.frame(width: 64, height: CGFloat.random(in: 32...92))
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}
}
}
Something similar what I want can be achieved by this:
extension View {
func readSize(edgesIgnoringSafeArea: Edge.Set = [], onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
SwiftUI.Color.clear
.preference(key: ReadSizePreferenceKey.self, value: geometryProxy.size)
}.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
)
.onPreferenceChange(ReadSizePreferenceKey.self) { size in
DispatchQueue.main.async { onChange(size) }
}
}
}
struct ReadSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct Size: Equatable {
var height: CGFloat
var isValid: Bool
}
struct TestView: View {
#State private var sizes = [Int: Size]()
#State private var height: CGFloat = 32
static let values: [(Int, CGFloat)] =
(0...3).map { ($0, CGFloat(32)) }
+ (4...10).map { ($0, CGFloat(92)) }
var body: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem(.fixed(height))],
alignment: .top,
spacing: 16
) {
ForEach(Array(Self.values), id: \.0) { value in
Color.red
.frame(width: 300, height: value.1)
.readSize { sizes[value.0]?.height = $0.height }
.onAppear {
if sizes[value.0] == nil {
sizes[value.0] = Size(height: .zero, isValid: true)
} else {
sizes[value.0]?.isValid = true
}
}
.onDisappear { sizes[value.0]?.isValid = false }
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}.onChange(of: sizes) { sizes in
height = sizes.filter { $0.1.isValid }.map { $0.1.height }.max() ?? 32
}
}
}
... but as you see its kind of laggy and a little bit complicated, isn't there better solution? Thank you everyone!
The height of a row in a LazyHGrid is driven by the height of the tallest cell. According to the example you provided, the data source will only show a smaller height if it has only a small size at the beginning.
Unless the first rendering will know that there are different heights, use the larger value as the height.
Is your expected UI behaviour that the height will automatically switch? Or use the highest height from the start.

How can I squeezing a Capsule while animation is active in SwiftUI

I have a Capsule which change it`s position on screen I want to make the Capsule get squeezed on both ends, but the animation does not allow me to do this, because it takes 2 View as start and end, and I just could manage left and right changing effect, so there is no room for squeezed effect to work! How can I do this as well? like apple done it.
struct CapsuleView: View {
#State private var startAnimation: Bool = Bool()
var body: some View {
return Capsule()
.fill(Color.secondary)
.frame(height: 5, alignment: .center)
.overlay(Capsule().fill(Color.green).frame(width: 100.0, height: 5, alignment: Alignment.center), alignment: startAnimation ? Alignment.trailing : Alignment.leading)
.onAppear() { startAnimation.toggle() }
.animation(Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: startAnimation)
}
}
Quick and dirty code, but based on #Zaphod`s proposal try using a mask and put your capsule behind it. then animate the offset based on the frame sizes
struct SnakeLoading: View {
#State
private var animating: Bool = true
private let height: CGFloat = 10
private let capsuleWidth: CGFloat = 100
func leadingOffset(size: CGSize) -> CGFloat {
(-size.width - capsuleWidth) * 0.5 + height
}
func trailingOffset(size: CGSize) -> CGFloat {
(size.width + capsuleWidth) * 0.5 - height
}
var body: some View {
GeometryReader { geo in
ZStack {
// Background
Rectangle()
.fill(Color.gray)
// Capsule
Capsule()
.fill(Color.green)
.offset(x: animating ? leadingOffset(size: geo.size) : trailingOffset(size: geo.size))
.frame(width: capsuleWidth, height: height)
.onAppear() { animating.toggle() }
.animation(Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: animating)
}
.mask(Capsule()
.frame(height: height)
)
}
.padding()
}
}

Switching a #State property to a #Binding property interferes with animation

I am currently building a bottom sheet UI like Apple Map's and I encountered myself with some weird bug that I am struggling to fix.
The following snippet represents the bottom sheet and includes a ready-to-go preview. You can try it out and see that everything works as expected in both the simulator and in XCode's preview engine.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#State private var opened = false
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
Now we switch the opened property to be a Binding property so that the parent view can know the state of the bottom sheet.
Here is the code with the changes.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Binding property that shares the state of the sheet to the parent view
#Binding private var opened: Bool
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
opened: Binding<Bool>,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self._opened = opened
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
opened: self.$opened,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
}
However, if you run the preview in XCode you'll see that when the drag gesture stops, instead of the sheet snapping back to the "opened" state from where we lift the finger off, it does the animation from its initial position.
Weirdly enough, if you try to run this in an iPhone or in the simulator, it behaves as expected, as in the first example. Although I've tried to use the code in a more "complex" app (it has a TabView and some few more things) and the same bug appears.
Any ideas why this is happening?
Here is an example with the example view used in a real app tested in a real iPhone. You'll see that the bug is very noticeable.
Video: https://imgur.com/a/RFZbqbD
Actually I see here some misunderstanding that must be clarified and, probably, then initial solution should be rethink. Binding is not state shared to parent, it is link to parent's state holding source of truth, so your view becomes dependent on parent's capability to refresh it on state change, which is not always reliable (or stable, or persistent, etc.), especially in different view hierarchies (like, sheets, UIKit backend, etc.). Changing binding you do not refresh your view directly (as opposite to changes own state) even if your view depends on value in binding, but change parent state, which might do or might do not update your view back. Finalizing - what you've implied is not reliable approach by nature, and you actually observe this.
Alternate solution: use ObsevableObject/ObservedObject view model pattern.
Tested with Xcode 12.4 / iOS 14.4
import MapKit
class ReleaseGestureVM: ObservableObject {
#Published var opened: Bool = false
}
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
#ObservedObject var vm: ReleaseGestureVM
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.vm.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.vm.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
vm: ReleaseGestureVM,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.vm = vm
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#StateObject private var vm = ReleaseGestureVM()
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
vm: self.vm,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}

SwiftUI - Sliding Text animation and positioning

On my journey to learn more about SwiftUI, I am still getting confused with positioning my element in a ZStack.
The goal is "simple", I wanna have a text that will slide within a defined area if the text is too long.
Let's say I have an area of 50px and the text takes 100. I want the text to slide from right to left and then left to right.
Currently, my code looks like the following:
struct ContentView: View {
#State private var animateSliding: Bool = false
private let timer = Timer.publish(every: 1, on: .current, in: .common).autoconnect()
private let slideDuration: Double = 3
private let text: String = "Hello, World! My name is Oleg and I would like this text to slide!"
var body: some View {
GeometryReader(content: { geometry in
VStack(content: {
Text("Hello, World! My name is Oleg!")
.font(.system(size: 20))
.id("SlidingText-Animation")
.alignmentGuide(VerticalAlignment.center, computeValue: { $0[VerticalAlignment.center] })
.position(y: geometry.size.height / 2 + self.textSize(fromWidth: geometry.size.width).height / 2)
.fixedSize()
.background(Color.red)
.animation(Animation.easeInOut(duration: 2).repeatForever())
.position(x: self.animateSliding ? -self.textSize(fromWidth: geometry.size.width).width : self.textSize(fromWidth: geometry.size.width).width)
.onAppear(perform: {
self.animateSliding.toggle()
})
})
.background(Color.yellow)
.clipShape(Rectangle())
})
.frame(width: 200, height: 80)
}
func textSize(fromWidth width: CGFloat, fontName: String = "System Font", fontSize: CGFloat = UIFont.systemFontSize) -> CGSize {
let text: UILabel = .init()
text.text = self.text
text.numberOfLines = 0
text.font = UIFont.systemFont(ofSize: 20) // (name: fontName, size: fontSize)
text.lineBreakMode = .byWordWrapping
return text.sizeThatFits(CGSize.init(width: width, height: .infinity))
}
}
Do you have any suggestion how to center the Text in Vertically in its parent and do the animation that starts at the good position?
Thank you for any future help, really appreciated!
EDIT:
I restructured my code, and change a couple things I was doing.
struct SlidingText: View {
let geometryProxy: GeometryProxy
#State private var animateSliding: Bool = false
private let timer = Timer.publish(every: 1, on: .current, in: .common).autoconnect()
private let slideDuration: Double = 3
private let text: String = "Hello, World! My name is Oleg and I would like this text to slide!"
var body: some View {
ZStack(alignment: .leading, content: {
Text(text)
.font(.system(size: 20))
// .lineLimit(1)
.id("SlidingText-Animation")
.fixedSize(horizontal: true, vertical: false)
.background(Color.red)
.animation(Animation.easeInOut(duration: slideDuration).repeatForever())
.offset(x: self.animateSliding ? -textSize().width : textSize().width)
.onAppear(perform: {
self.animateSliding.toggle()
})
})
.frame(width: self.geometryProxy.size.width, height: self.geometryProxy.size.height)
.clipShape(Rectangle())
.background(Color.yellow)
}
func textSize(fontName: String = "System Font", fontSize: CGFloat = 20) -> CGSize {
let text: UILabel = .init()
text.text = self.text
text.numberOfLines = 0
text.font = UIFont(name: fontName, size: fontSize)
text.lineBreakMode = .byWordWrapping
let textSize = text.sizeThatFits(CGSize(width: self.geometryProxy.size.width, height: .infinity))
print(textSize.width)
return textSize
}
}
struct ContentView: View {
var body: some View {
GeometryReader(content: { geometry in
SlidingText(geometryProxy: geometry)
})
.frame(width: 200, height: 40)
}
}
Now the animation looks pretty good, except that I have padding on both right and left which I don't understand why.
Edit2: I also notice by changing the text.font = UIFont.systemFont(ofSize: 20) by text.font = UIFont.systemFont(ofSize: 15) makes the text fits correctly. I don't understand if there is a difference between the system font from SwiftUI or UIKit. It shouldn't ..
Final EDIT with Solution in my case:
struct SlidingText: View {
let geometryProxy: GeometryProxy
#Binding var text: String
#Binding var fontSize: CGFloat
#State private var animateSliding: Bool = false
private let timer = Timer.publish(every: 5, on: .current, in: .common).autoconnect()
private let slideDuration: Double = 3
var body: some View {
ZStack(alignment: .leading, content: {
VStack {
Text(text)
.font(.system(size: self.fontSize))
.background(Color.red)
}
.fixedSize()
.frame(width: geometryProxy.size.width, alignment: animateSliding ? .trailing : .leading)
.clipped()
.animation(Animation.linear(duration: slideDuration))
.onReceive(timer) { _ in
self.animateSliding.toggle()
}
})
.frame(width: self.geometryProxy.size.width, height: self.geometryProxy.size.height)
.clipShape(Rectangle())
.background(Color.yellow)
}
}
struct ContentView: View {
#State var text: String = "Hello, World! My name is Oleg and I would like this text to slide!"
#State var fontSize: CGFloat = 20
var body: some View {
GeometryReader(content: { geometry in
SlidingText(geometryProxy: geometry, text: self.$text, fontSize: self.$fontSize)
})
.frame(width: 400, height: 40)
.padding(0)
}
}
Here's the result visually.
Here is a possible simple approach - the idea is just as simple as to change text alignment in container, anything else can be tuned as usual.
Demo prepared & tested with Xcode 12 / iOS 14
Update: retested with Xcode 13.3 / iOS 15.4
struct DemoSlideText: View {
let text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor"
#State private var go = false
var body: some View {
VStack {
Text(text)
}
.fixedSize()
.frame(width: 300, alignment: go ? .trailing : .leading)
.clipped()
.onAppear { self.go.toggle() }
.animation(Animation.linear(duration: 5).repeatForever(autoreverses: true), value: go)
}
}
One way to do this is by using the "PreferenceKey" protocol, in conjunction with the "preference" and "onPreferenceChange" modifiers.
It's SwiftUI's way to send data from child Views to parent Views.
Code:
struct ContentView: View {
#State private var offset: CGFloat = 0.0
private let text: String = "Hello, World! My name is Oleg and I would like this text to slide!"
var body: some View {
// Capturing the width of the screen
GeometryReader { screenGeo in
ZStack {
Color.yellow
.frame(height: 50)
Text(text)
.fixedSize()
.background(
// Capturing the width of the text
GeometryReader { geo in
Color.red
// Sending width difference to parent View
.preference(key: MyTextPreferenceKey.self, value: screenGeo.size.width - geo.frame(in: .local).width
)
}
)
}
.offset(x: self.offset)
// Receiving width from child view
.onPreferenceChange(MyTextPreferenceKey.self, perform: { width in
withAnimation(Animation.easeInOut(duration: 1).repeatForever()) {
self.offset = width
}
})
}
}
}
// MARK: PreferenceKey
struct MyTextPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0.0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Result:
Using some inspiration from you guys, I end up doing something way simpler by using only the Animation functions/properties.
import SwiftUI
struct SlidingText: View {
let geometryProxy: GeometryProxy
#Binding var text: String
let font: Font
#State private var animateSliding: Bool = false
private let slideDelay: Double = 3
private let slideDuration: Double = 6
private var isTextLargerThanView: Bool {
if text.size(forWidth: geometryProxy.size.width, andFont: font).width < geometryProxy.size.width {
return false
}
return true
}
var body: some View {
ZStack(alignment: .leading, content: {
VStack(content: {
Text(text)
.font(self.font)
.foregroundColor(.white)
})
.id("SlidingText-Animation")
.fixedSize()
.animation(isTextLargerThanView ? Animation.linear(duration: slideDuration).delay(slideDelay).repeatForever(autoreverses: true) : nil)
.frame(width: geometryProxy.size.width,
alignment: isTextLargerThanView ? (animateSliding ? .trailing : .leading) : .center)
.onAppear(perform: {
self.animateSliding.toggle()
})
})
.clipped()
}
}
And I call the SlidingView
GeometryReader(content: { geometry in
SlidingText(geometryProxy: geometry,
text: self.$playerViewModel.seasonByline,
font: .custom("FoundersGrotesk-RegularRegular", size: 15))
})
.frame(height: 20)
.fixedSize(horizontal: false, vertical: true)
Thank you again for your help!

Show line / separator view in SwiftUI

I want to show a separator line in my SwiftUI app. To achieve that, I tried to create an empty view with a fixed frame and a background color / border:
EmptyView()
.frame(width: 200, height: 2)
.background(Color.black) // or:
.border(Color.black, width: 2)
Unfortunately, I cannot see any dark view showing up.
Is there a way to show a separator / line view?
Use Divider:
A visual element that can be used to separate other content.
Example:
struct ContentView : View {
var body: some View {
VStack {
Text("Hello World")
Divider()
Text("Hello Another World")
}
}
}
Output:
If anyone is interested a divider, text, divider, looking like this:
LabelledDivider code
struct LabelledDivider: View {
let label: String
let horizontalPadding: CGFloat
let color: Color
init(label: String, horizontalPadding: CGFloat = 20, color: Color = .gray) {
self.label = label
self.horizontalPadding = horizontalPadding
self.color = color
}
var body: some View {
HStack {
line
Text(label).foregroundColor(color)
line
}
}
var line: some View {
VStack { Divider().background(color) }.padding(horizontalPadding)
}
}
It's kind of ugly but I had to put the Dividers into a VStack to make them horizontal, otherwise, they will be vertical, due to HStack. Please let me know if you managed to simplify this :)
Also maybe using and stored properties for LabelledDivider might not be the most SwiftUI-y solution, so I'm open to improvements.
Example usage
This is the code that results in the screenshot seen above:
struct GetStartedView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: SignInView()) {
Text("Sign In").buttonStyleEmerald()
}
LabelledDivider(label: "or")
NavigationLink(destination: SignUpView()) {
Text("Sign up").buttonStyleSaphire()
}
}.padding(20)
}
}
}
ButtonStyle
For sake of completness, I also include buttonStyle view modifiers:
struct ButtonStyle: ViewModifier {
private let color: Color
private let enabled: () -> Bool
init(color: Color, enabled: #escaping () -> Bool = { true }) {
self.color = color
self.enabled = enabled
}
dynamic func body(content: Content) -> some View {
content
.padding()
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
.foregroundColor(Color.white)
.background(enabled() ? color : Color.black)
.cornerRadius(5)
}
}
extension View {
dynamic func buttonStyleEmerald(enabled: #escaping () -> Bool = { true }) -> some View {
ModifiedContent(content: self, modifier: ButtonStyle(color: Color.emerald, enabled: enabled))
}
dynamic func buttonStyleSaphire(enabled: #escaping () -> Bool = { true }) -> some View {
ModifiedContent(content: self, modifier: ButtonStyle(color: Color.saphire, enabled: enabled))
}
}
Edit: Please note that Color.saphire and Color.emerald are custom declared colors:
extension Color {
static var emerald: Color { .rgb(036, 180, 126) }
static var forest: Color { .rgb(062, 207, 142) }
}
extension Color {
static func rgb(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> Color {
func value(_ raw: UInt8) -> Double {
return Double(raw)/Double(255)
}
return Color(
red: value(red),
green: value(green),
blue: value(blue)
)
}
}
You can just draw a line by using Color. If you want to change the line width or padding, you can use frame or padding like other SwiftUI Components.
//Horizontal Line in VStack
VStack{
Color.gray.frame(height: 1 / UIScreen.main.scale)
}
//Vertical Line in HStack
HStack{
Color.gray.frame(width: 1 / UIScreen.main.scale)
}
If you are looking for a way to customize the divider, there isn't any. You must provide your custom implementation:
struct CustomDivider: View {
let height: CGFloat = 1
let color: Color = .white
let opacity: Double = 0.2
var body: some View {
Group {
Rectangle()
}
.frame(height: height)
.foregroundColor(color)
.opacity(opacity)
}
}
HStack {
VStack {
Divider()
}
Text("or")
.font(.caption)
.foregroundColor(Color(UIColor.systemGray))
VStack {
Divider()
}
}