Fill remaining whitespace in text line with dots (multiline text) iOS - ios

I want to fill the remaining whitespace from the last line with a dotted line. It should start at the end of the last word and continue until the end of the line. Is this possible with SwiftUI or even UIKit?
What I have:
What I need:
struct ContentView: View {
var body: some View {
let fontSize = UIFont.preferredFont(forTextStyle: .headline).lineHeight
let text = "stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow"
HStack(alignment: .lastTextBaseline, spacing: .zero) {
HStack(alignment: .top, spacing: .zero) {
Circle()
.foregroundColor(.green)
.frame(width: 6, height: 6)
.frame(height: fontSize, alignment: .center)
ZStack(alignment: .bottom) {
HStack(alignment: .lastTextBaseline, spacing: .zero) {
Text("")
.font(.headline)
.padding(.leading, 5)
Spacer(minLength: 10)
.overlay(Line(), alignment: .bottom)
}
HStack(alignment: .lastTextBaseline, spacing: .zero) {
Text(text)
.font(.headline)
.padding(.leading, 5)
Spacer(minLength: 10)
}
}
}
}
}
}
struct Line: View {
var width: CGFloat = 1
var color = Color.gray
var body: some View {
LineShape(width: width)
.stroke(style: StrokeStyle(lineWidth: 3, dash: [3]))
.foregroundColor(color)
.frame(height: width)
}
}
private struct LineShape: Shape {
var width: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint())
path.addLine(to: CGPoint(x: rect.width, y: .zero))
return path
}
}

Here's my slightly hacky, but more simple, solution: add a white highlight to the text, so it covers the dotted line.
We can add a highlight with NSAttributedString. SwiftUI doesn't support this by default, so we need to use UIViewRepresentable. Here it is, based off this answer:
struct HighlightedText: View {
var text: String
#State private var height: CGFloat = .zero
private var fontStyle: UIFont.TextStyle = .body
init(_ text: String) { self.text = text }
var body: some View {
InternalHighlightedText(text: text, dynamicHeight: $height, fontStyle: fontStyle)
.frame(minHeight: height) /// allow text wrapping
.fixedSize(horizontal: false, vertical: true) /// preserve the Text sizing
}
struct InternalHighlightedText: UIViewRepresentable {
var text: String
#Binding var dynamicHeight: CGFloat
var fontStyle: UIFont.TextStyle
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
label.font = UIFont.preferredFont(forTextStyle: fontStyle)
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
let attributedText = NSAttributedString(string: text, attributes: [.backgroundColor: UIColor.systemBackground])
uiView.attributedText = attributedText /// set white background color here
uiView.font = UIFont.preferredFont(forTextStyle: fontStyle)
DispatchQueue.main.async {
dynamicHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height
}
}
}
/// enable .font modifier
func font(_ fontStyle: UIFont.TextStyle) -> HighlightedText {
var view = self
view.fontStyle = fontStyle
return view
}
}
Then, just replace Text(text) with HighlightedText(text).
struct ContentView: View {
var body: some View {
let fontSize = UIFont.preferredFont(forTextStyle: .headline).lineHeight
let text = "stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow stackoverflow"
HStack(alignment: .lastTextBaseline, spacing: .zero) {
HStack(alignment: .top, spacing: .zero) {
Circle()
.foregroundColor(.green)
.frame(width: 6, height: 6)
.frame(height: fontSize, alignment: .center)
ZStack(alignment: .bottom) {
HStack(alignment: .lastTextBaseline, spacing: .zero) {
Text("")
.font(.headline)
.padding(.leading, 5)
Spacer(minLength: 10)
.overlay(Line(), alignment: .bottom)
}
HStack(alignment: .lastTextBaseline, spacing: .zero) {
HighlightedText(text) /// here!
.font(.headline)
.padding(.leading, 5)
Spacer(minLength: 10)
}
}
}
}
}
}
struct Line: View {
var width: CGFloat = 1
var color = Color.gray
var body: some View {
LineShape(width: width)
.stroke(style: StrokeStyle(lineWidth: 3, dash: [3]))
.foregroundColor(color)
.frame(height: width)
}
}
private struct LineShape: Shape {
var width: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint())
path.addLine(to: CGPoint(x: rect.width, y: .zero))
return path
}
}
Before
After

You can do that with UIKit:
Find the text in the last line using this approach: https://stackoverflow.com/a/14413484/2051369
Get the size of last line using let size = lastLineText.size(withAttributes: [.font: label.font]) ?? .zero
let gap = label.frame.width - size.width
let dotsCount = gap / dotWidth
let resultText = sourceText + String(repeating: " .", count: dotsCount)

Related

How to disable highlighted text in TextField in Swift UI

I created an OTP field using textfield but I want to disable highlighting the text when you double tap or longpress on a TextField.
I tried adjusting the font size to 0. This seems to shrink the grey highlight but it did not hide it totally.
I can't use textSelection(.disabled) because I can only use Xcode 12.3 and this API seems to be available in higher Xcode versions.
I also cannot adjust the frame width of textField to 0 when editing is true because the paste functionality would be disable. Paste to text is a needed requirement.
Here is my code:
import SwiftUI
#available(iOS 13.0, *)
class OTPViewModel: ObservableObject {
var numberOfFields: Int
init(numberOfFields: Int = 6) {
self.numberOfFields = numberOfFields
}
#Published var otpField = "" {
didSet {
guard otpField.last?.isNumber ?? true else {
otpField = oldValue
return
}
if otpField.count == numberOfFields {
hideKeyboard()
}
}
}
#Published var isEditing = false
func otp(digit: Int) -> String {
guard otpField.count >= digit else {
return ""
}
return String(Array(otpField)[digit - 1])
}
private func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
#available(iOS 13.0, *)
struct OTPView: View {
#ObservedObject var viewModel = OTPViewModel()
private let textBoxWidth: CGFloat = 41
private let textBoxHeight = UIScreen.main.bounds.width / 8
private let spaceBetweenLines: CGFloat = 16
private let paddingOfBox: CGFloat = 1
private var textFieldOriginalWidth: CGFloat {
(textBoxWidth + CGFloat(18)) * CGFloat(viewModel.numberOfFields)
}
var body: some View {
VStack {
ZStack {
HStack (spacing: spaceBetweenLines) {
ForEach(1 ... viewModel.numberOfFields, id: \.self) { digit in
otpText(
text: viewModel.otp(digit: digit),
isEditing: viewModel.isEditing,
beforeCursor: digit - 1 < viewModel.otpField.count,
afterCursor: viewModel.otpField.count < digit - 1
)
}
} //: HSTACK
TextField("", text: $viewModel.otpField) { isEditing in
viewModel.isEditing = isEditing
}
.font(Font.system(size: 90, design: .default))
.offset(x: 12, y: 10)
.frame(width: textFieldOriginalWidth, height: textBoxHeight)
.textContentType(.oneTimeCode)
.foregroundColor(.clear)
.background(Color.clear)
.keyboardType(.decimalPad)
.accentColor(.clear)
} //: ZSTACK
} //: VSTACK
}
#available(iOS 13.0, *)
private func otpText(
text: String,
isEditing: Bool,
beforeCursor: Bool,
afterCursor: Bool
) -> some View {
return Text(text)
.font(Font.custom("GTWalsheim-Regular", size: 34))
.frame(width: textBoxWidth, height: textBoxHeight)
.background(VStack{
Spacer()
.frame(height: 65)
ZStack {
Capsule()
.frame(width: textBoxWidth, height: 2)
.foregroundColor(Color(hex: "#BCBEC0"))
Capsule()
.frame(width: textBoxWidth, height: 2)
.foregroundColor(Color(hex: "#367878"))
.offset(x: (beforeCursor ? textBoxWidth : 0) + (afterCursor ? -textBoxWidth : 0))
.animation(.easeInOut, value: [beforeCursor, afterCursor])
.opacity(isEditing ? 1 : 0)
} //: ZSTACK
.clipped()
})
.padding(paddingOfBox)
.accentColor(.clear)
}
}
In the frame of the TextField.
.frame(width: textFieldOriginalWidth, height: textBoxHeight)
Change the width to be reactive to the isEditing state. If true make the width 0 otherwise make it textFieldOriginalWidth
like this:
.frame(width: isEditing ? 0 : textFieldOriginalWidth, height: textBoxHeight)
Of course this doesn't disable it. But it will not highlight and allow the user to past, copy, etc...
It will have the desired outcome.
Update
To get the OTP to "Autofill" or show up in the keyboard.
Set the Textfield's .textContentType as .oneTimeCode.
The OS should handle the rest read the Apple docs.
Which you have done for textfield.
This action should paste your copied text:
Button(action: paste, label: {
Text("Paste")
})
.
.
.
func paste() {
let pasteboard = UIPasteboard.general
guard let pastedString = pasteboard.string else {
return
}
viewModel.otpField = pastedString
}
The solution is to make the frame of TextField's width equal to 0 when editing but doing this will disable paste functionality so #Alhomaidhi's solution is to add a paste button.
I did this and made it appear when TextField is double or long tap to mimic the iOS clipboard.
Here is the complete code:
import SwiftUI
#available(iOS 13.0, *)
class OTPViewModel: ObservableObject {
var numberOfFields: Int
init(numberOfFields: Int = 6) {
self.numberOfFields = numberOfFields
}
#Published var otpField = "" {
didSet {
showPasteButton = false
guard otpField.last?.isNumber ?? true else {
otpField = oldValue
return
}
if otpField.count == numberOfFields {
hideKeyboard()
showPasteButton = false
}
}
}
#Published var isEditing = false {
didSet {
if !isEditing { showPasteButton = isEditing }
}
}
#Published var showPasteButton = false
func otp(digit: Int) -> String {
guard otpField.count >= digit else {
return ""
}
return String(Array(otpField)[digit - 1])
}
private func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
#available(iOS 13.0, *)
struct CXLRPOTPView: View {
#ObservedObject var viewModel = OTPViewModel()
#Environment(\.colorScheme) var colorScheme
private let textBoxWidth: CGFloat = 41
private let textBoxHeight = UIScreen.main.bounds.width / 8
private let spaceBetweenLines: CGFloat = 16
private let paddingOfBox: CGFloat = 1
private var textFieldOriginalWidth: CGFloat {
(textBoxWidth + CGFloat(18)) * CGFloat(viewModel.numberOfFields)
}
var body: some View {
VStack {
ZStack {
// DOUBLE TAP AND LONG PRESS LISTENER
Text("123456")
.onTapGesture(count: 2) {
viewModel.showPasteButton = true
}
.frame(width: textFieldOriginalWidth, height: textBoxHeight)
.background(Color.clear)
.font(Font.system(size: 90, design: .default))
.foregroundColor(Color.clear)
.onLongPressGesture(minimumDuration: 0.5) {
self.viewModel.showPasteButton = true
}
// OTP TEXT
HStack (spacing: spaceBetweenLines) {
ForEach(1 ... viewModel.numberOfFields, id: \.self) { digit in
otpText(
text: viewModel.otp(digit: digit),
isEditing: viewModel.isEditing,
beforeCursor: digit - 1 < viewModel.otpField.count,
afterCursor: viewModel.otpField.count < digit - 1
)
}
} //: HSTACK
// TEXTFIELD FOR EDITING
TextField("", text: $viewModel.otpField) { isEditing in
viewModel.isEditing = isEditing
}
.font(Font.system(size: 90, design: .default))
.offset(x: 12, y: 10)
.frame(width: viewModel.isEditing ? 0 : textFieldOriginalWidth, height: textBoxHeight) // SOLUTION THAT PREVENTED TEXT HIGHLIGHT
.textContentType(.oneTimeCode)
.foregroundColor(.clear)
.background(Color.clear)
.keyboardType(.numberPad)
.accentColor(.clear)
// PASTE BUTTON
Button(action: pasteText, label: {
Text("Paste")
})
.padding(.top, 9)
.padding(.bottom, 9)
.padding(.trailing, 16)
.padding(.leading, 16)
.font(Font.system(size: 14, design: .default))
.accentColor(Color(.white))
.background(Color(colorScheme == .light ? UIColor.black : UIColor.systemGray6))
.cornerRadius(7.0)
.overlay(
RoundedRectangle(cornerRadius: 7).stroke(Color(.black), lineWidth: 2)
)
.opacity(viewModel.showPasteButton ? 1 : 0)
.offset(x: viewModel.numberOfFields >= 6 ? -150 : -100, y: -40)
} //: ZSTACK
} //: VSTACK
}
func pasteText() {
let pasteboard = UIPasteboard.general
guard let pastedString = pasteboard.string else {
return
}
let otpField = pastedString.prefix(viewModel.numberOfFields)
viewModel.otpField = String(otpField)
}
#available(iOS 13.0, *)
private func otpText(
text: String,
isEditing: Bool,
beforeCursor: Bool,
afterCursor: Bool
) -> some View {
return Text(text)
.font(Font.custom("GTWalsheim-Regular", size: 34))
.frame(width: textBoxWidth, height: textBoxHeight)
.background(VStack{
Spacer()
.frame(height: 65)
ZStack {
Capsule()
.frame(width: textBoxWidth, height: 2)
.foregroundColor(Color(hex: "#BCBEC0"))
Capsule()
.frame(width: textBoxWidth, height: 2)
.foregroundColor(Color(hex: "#367878"))
.offset(x: (beforeCursor ? textBoxWidth : 0) + (afterCursor ? -textBoxWidth : 0))
.animation(.easeInOut, value: [beforeCursor, afterCursor])
.opacity(isEditing ? 1 : 0)
} //: ZSTACK
.clipped()
})
.padding(paddingOfBox)
.foregroundColor(Color.black)
}
}
#available(iOS 13.0.0, *)
struct CXLRPOTPView_Previews: PreviewProvider {
static var previews: some View {
CXLRPOTPView(viewModel: OTPViewModel())
.previewLayout(.sizeThatFits)
}
}

SwiftUI TextEditor Initial Content Size Is Wrong

iOS 14.4 + Xcode 12.4
I want to make a simple checklist in SwiftUI on iOS where the text for each item is a TextEditor.
First, I create the basic app structure and populate it with some demo content:
import SwiftUI
#main
struct TestApp: App {
#State var alpha = "Alpha"
#State var bravo = "Bravo is a really long one that should wrap to multiple lines."
#State var charlie = "Charlie"
init(){
//Remove the default background of the TextEditor/UITextView
UITextView.appearance().backgroundColor = .clear
}
var body: some Scene {
WindowGroup {
ScrollView{
VStack(spacing: 7){
TaskView(text: $alpha)
TaskView(text: $bravo)
TaskView(text: $charlie)
}
.padding(20)
}
.background(Color.gray)
}
}
}
Then each TaskView represents a task (the white box) in the list:
struct TaskView:View{
#Binding var text:String
var body: some View{
HStack(alignment:.top, spacing:8){
Button(action: {
print("Test")
}){
Circle()
.strokeBorder(Color.gray,lineWidth: 1)
.background(Circle().foregroundColor(Color.white))
.frame(width:22, height: 22)
}
.buttonStyle(PlainButtonStyle())
FieldView(name: $text)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(EdgeInsets(top:10, leading:10, bottom: 10, trailing: 30))
.background(Color.white)
.cornerRadius(5)
}
}
Then finally, each of the TextEditors is in a FieldView like this:
struct FieldView: View{
#Binding var name: String
var body: some View{
ZStack{
Text(name)
.padding(EdgeInsets(top: -7, leading: -3, bottom: -5, trailing: -3))
.opacity(0)
TextEditor(text: $name)
.fixedSize(horizontal: false, vertical: true)
.padding(EdgeInsets(top: -7, leading: -3, bottom: -5, trailing: -3))
}
}
}
As you can see in the screenshot above, the initial height of the TextEditor doesn't automatically size to fit the text. But as soon as I type in it, it resizes appropriately. Here's a video that shows that:
How can I get the view to have the correct initial height? Before I type in it, the TextEditor scrolls vertically so it seems to have the wrong intrinsic content size.
Note: views are left semi-transparent with borders so you can see/debug what's going on.
struct FieldView: View{
#Binding var name: String
#State private var textEditorHeight : CGFloat = 100
var body: some View{
ZStack(alignment: .topLeading) {
Text(name)
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
//.opacity(0)
.border(Color.pink)
.foregroundColor(Color.red)
TextEditor(text: $name)
.padding(EdgeInsets(top: -7, leading: -3, bottom: -5, trailing: -7))
.frame(height: textEditorHeight + 12)
.border(Color.green)
.opacity(0.4)
}
.onPreferenceChange(ViewHeightKey.self) { textEditorHeight = $0 }
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
print("Reporting height: \(value)")
}
}
First, I used a PreferenceKey to pass the height from the "invisible" text view back up the view hierarchy. Then, I set the height of the TextEditor frame with that value.
Note that the view is now aligned to topLeading -- in your initial example, the invisible text was center aligned.
One thing I'm not crazy about is the use of the edge insets -- these feel like magic numbers (well, they are...) and I'd rather have a solution without them that still kept the Text and TextEditor completely aligned. But, this works for now.
Update, using UIViewRepresentable with UITextView
This seems to work and avoid the scrolling problems:
struct TaskView:View{
#Binding var text:String
#State private var textHeight : CGFloat = 40
var body: some View{
HStack(alignment:.top, spacing:8){
Button(action: {
print("Test")
}){
Circle()
.strokeBorder(Color.gray,lineWidth: 1)
.background(Circle().foregroundColor(Color.white))
.frame(width:22, height: 22)
}
.buttonStyle(PlainButtonStyle())
FieldView(text: $text, heightToTransmit: $textHeight)
.frame(height: textHeight)
.border(Color.red)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(EdgeInsets(top:10, leading:10, bottom: 10, trailing: 30))
.background(Color.white)
.cornerRadius(5)
}
}
struct FieldView : UIViewRepresentable {
#Binding var text : String
#Binding var heightToTransmit: CGFloat
func makeUIView(context: Context) -> UIView {
let view = UIView()
let textView = UITextView(frame: .zero, textContainer: nil)
textView.delegate = context.coordinator
textView.backgroundColor = .yellow // visual debugging
textView.isScrollEnabled = false // causes expanding height
context.coordinator.textView = textView
textView.text = text
view.addSubview(textView)
// Auto Layout
textView.translatesAutoresizingMaskIntoConstraints = false
let safeArea = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: safeArea.topAnchor),
textView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor)
])
return view
}
func updateUIView(_ view: UIView, context: Context) {
context.coordinator.heightBinding = $heightToTransmit
context.coordinator.textBinding = $text
DispatchQueue.main.async {
context.coordinator.runSizing()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
class Coordinator : NSObject, UITextViewDelegate {
var textBinding : Binding<String>?
var heightBinding : Binding<CGFloat>?
var textView : UITextView?
func runSizing() {
guard let textView = textView else { return }
textView.sizeToFit()
self.textBinding?.wrappedValue = textView.text
self.heightBinding?.wrappedValue = textView.frame.size.height
}
func textViewDidChange(_ textView: UITextView) {
runSizing()
}
}
}

Best approach getting subviews to layout vertically in SwiftUI?

I'm having a hard time getting subviews to lay out correctly, especially the heights. I hate to hard code the subview heights but that is the only thing I can get to work. If I remove these frame(heights) from the subviews, the subviews overlap in the parent view. Any suggestions for a better way to architect this without the hard coded heights?
struct SkateDetailView: View {
#EnvironmentObject var combineWorkoutManager: CombineWorkoutManager
#Environment(\.colorScheme) var colorScheme
#State var subscriptionIsActive = false
var body: some View {
ScrollView {
VStack(alignment: .leading) {
combineWorkoutManager.workout.map { SkateDetailHeaderView(workout: $0)}
combineWorkoutManager.heartRateSamplesFromWorkout.map { HeartRateRecoveryCard(heartRateSamples: $0) }
combineWorkoutManager.vo2MaxFromWorkout.map { vo2MaxCard(vo2MaxValue: $0) }
}
.padding(.top)
.background(colorScheme == .dark ? Color.black : Color.offWhite)
.onAppear {
combineWorkoutManager.loadHeartRatesFromWorkout()
combineWorkoutManager.loadVo2MaxFromWorkout()
combineWorkoutManager.getWorkout()
self.subscriptionIsActive = UserDefaults.standard.bool(forKey: subscriptionIsActiveKey)
}
//Code for conditional view modifer from: https://fivestars.blog/swiftui/conditional-modifiers.html
.if(subscriptionIsActive) { $0.redacted(reason: .placeholder)}
}
}
}
struct SkateDetailHeaderView: View {
var workout: HKWorkout
#Environment(\.colorScheme) var colorScheme
#State var sessionTypeImage = Image("Game_playerhelmet")
var body: some View {
ZStack(alignment: .leading) {
(colorScheme == .dark ? Color.black : Color.white)
.cornerRadius(10)
VStack(alignment: .leading) {
HStack() {
sessionTypeImage
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 35.0, height: 35.0)
.unredacted()
Text(WorkoutManager.ReadOutHockeyTrackerMetadata(workout: workout)?.sessionType ?? "")
.font(.custom(futuraLTPro, size: largeTitleTextSize))
.unredacted()
}
.padding(.leading)
.onAppear {
setSessionTypeImage()
}
HStack {
Text("Goals")
Text("Assists")
Text("+/-")
}
.font(.custom(futuraMedium, size: captionTextSize))
.unredacted()
}
.padding(.all)
}
.padding(.horizontal)
}
struct HeartRateRecoveryCard: View {
//Don't download HR samples for this view the parent view should download HR samples and pass them into it's children views
#State var heartRateSamples: [HKQuantitySample]
#State var HRRAnchor = 0
#State var HRRTwoMinutesLater = 0
#State var heartRateRecoveryValue = 0
#Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : Color.white)
.cornerRadius(10)
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text("Heart Rate Recovery")
.font(.custom(futuraLTPro, size: titleTextSize))
.unredacted()
Text("\(heartRateRecoveryValue) bpm")
.font(.custom(futuraMedium, size: titleTextSize))
.font(Font.body.monospacedDigit())
Text("\(HRRAnchor) bpm - \(HRRTwoMinutesLater) bpm")
.font(.custom(futuraMedium, size: captionTextSize))
.font(Font.body.monospacedDigit())
.foregroundColor(Color(.secondaryLabel))
}
.padding(.leading)
SwiftUILineChart(chartLabelName: "Heart Rate Recovery", entries: convertHeartRatesToLineChartDataAndSetFormatter(heartRates: heartRateSamples).0, chartLineAndGradientColor: ColorCompatibility.label, xAxisFormatter: convertHeartRatesToLineChartDataAndSetFormatter(heartRates: heartRateSamples).1, showGradient: false)
.frame(width: 350, height: 180, alignment: .center)
.cornerRadius(12)
.onAppear {
if let unwrappedHRRObject = HeartRateRecoveryManager.calculateHeartRateRecoveryForHRRGraph(heartRateSamples: heartRateSamples) {
HRRAnchor = unwrappedHRRObject.anchorHR
HRRTwoMinutesLater = unwrappedHRRObject.twoMinLaterHR
heartRateRecoveryValue = unwrappedHRRObject.hrr
print("HRR = \(heartRateRecoveryValue)")
}
}
BarScaleView(valueToBeScaled: heartRateRecoveryValue, scaleMax: 60, numberOfBlocks: 5, colors: [logoAquaShade1Color, logoAquaShade2Color, logoAquaShade3Color, logoAquaShade4Color, logoAquaShade5Color], blockValues: [0, 12, 24, 36, 48])
}
.padding(.top)
}
.frame(height: 375)
.padding(.all)
}
struct vo2MaxCard: View {
#State var vo2MaxValue: Int
#Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : Color.white)
.cornerRadius(10)
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text("VO2 Max")
.font(.custom(futuraLTPro, size: titleTextSize))
.padding(.top)
.unredacted()
Text("\(vo2MaxValue) mL/(kg - min)")
.font(.custom(futuraMedium, size: titleTextSize))
.font(Font.body.monospacedDigit())
}
.padding(.leading)
BarScaleView(valueToBeScaled: vo2MaxValue, scaleMax: 72, numberOfBlocks: 4, colors: [logoRedShade1Color, logoRedShade2Color, logoRedShade3Color, logoRedShade4Color, logoRedShade5Color], blockValues: [0, 18, 36, 54])
}
}
.frame(height: 175)
.padding(.all)
}
}
struct BarScaleView: View {
var valueToBeScaled: Int
var scaleMax: Int
var numberOfBlocks: Int
var colors: [UIColor]
var blockValues: [Int] //e.g. 0, 12, 24, 36, 48
var body: some View {
GeometryReader { geometry in
VStack {
HStack(spacing: 0) {
ForEach(0..<numberOfBlocks) { index in
ScaleBarBlock(numberOfBlocks: numberOfBlocks, blockColor: Color(colors[index]), proxy: geometry, blockValue: blockValues[index])
}
}
Image(systemName: "triangle.fill")
.padding(.top)
.unredacted()
// .if(subscriptionIsActive) { $0.redacted(reason: .placeholder)}
.if(valueToBeScaled >= scaleMax) { $0.position(x: geometry.size.width - 5) }
.if(valueToBeScaled < scaleMax) { $0.position(x: geometry.size.width * CGFloat(Double(valueToBeScaled) / Double(scaleMax))) }
}
}
.padding()
}
}
struct SwiftUILineChart: UIViewRepresentable {
var chartLabelName: String
//Line Chart accepts data as array of BarChartDataEntry objects
var entries: [ChartDataEntry]
var chartLineAndGradientColor = logoRedShade1Color
var xAxisFormatter: ChartXAxisFormatter?
var showGradient = true
// this func is required to conform to UIViewRepresentable protocol
func makeUIView(context: Context) -> LineChartView {
//crate new chart
let chart = LineChartView()
//it is convenient to form chart data in a separate func
chart.data = addData()
chart.backgroundColor = .clear
chart.pinchZoomEnabled = false
chart.dragEnabled = false
chart.isUserInteractionEnabled = false
//If available pass xAsisFormatter to chart
if let unwrappedxAxisFormatter = xAxisFormatter {
chart.xAxis.valueFormatter = unwrappedxAxisFormatter
chart.xAxis.setLabelCount(5, force: true) //set number of labels at top of graph to avoid too many (Not using since setting granularity works)
chart.xAxis.avoidFirstLastClippingEnabled = true
chart.xAxis.labelPosition = .bottom
}
return chart
}
// this func is required to conform to UIViewRepresentable protocol
func updateUIView(_ uiView: LineChartView, context: Context) {
//when data changes chartd.data update is required
uiView.data = addData()
}
func addData() -> LineChartData {
let data = LineChartData()
//BarChartDataSet is an object that contains information about your data, styling and more
let dataSet = LineChartDataSet(entries: entries)
if showGradient == true {
let gradientColors = [chartLineAndGradientColor.cgColor, UIColor.clear.cgColor]
let colorLocations: [CGFloat] = [1.0, 0.0] //positioning of gradient
let gradient = CGGradient(colorsSpace: nil, colors: gradientColors as CFArray, locations: colorLocations)!
dataSet.fillAlpha = 1
dataSet.fill = Fill(linearGradient: gradient, angle: 90)
dataSet.drawFilledEnabled = true
}
// change bars color to green
dataSet.colors = [chartLineAndGradientColor]
dataSet.drawCirclesEnabled = false //no circles
dataSet.drawValuesEnabled = false //hide labels on the datapoints themselves
//change data label
dataSet.label = chartLabelName
data.addDataSet(dataSet)
return data
}
}

SwiftUI - Autoscroll horizontal scrollview to show full text label

I have the following code which works perfectly. But the one issue I can not solve if that when on the of the Titles is particularly visible in the scrollview and the user click on the portion of the text that is visible, not only do I want to have the title selected I would like for the scollview to "auto scroll" so that the full title is displayed in the scrollview vs only the partial text.
import SwiftUI
struct CustomSegmentedPickerView: View {
#State private var selectedIndex = 0
private var titles = ["Round Trip", "One Way", "Multi-City", "Other"]
private var colors = [Color.red, Color.green, Color.blue, Color.yellow]
#State private var frames = Array<CGRect>(repeating: .zero, count: 4)
var body: some View {
VStack {
ScrollView(.horizontal, showsIndicators: false) {
ZStack {
HStack(spacing: 10) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
}.padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20)).background(
GeometryReader { geo in
Color.clear.onAppear { self.setFrame(index: index, frame: geo.frame(in: .global)) }
})
}
}
.background(
Capsule().fill(self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
Picker(selection: self.$selectedIndex, label: Text("What is your favorite color?")) {
ForEach(0..<self.titles.count) { index in
Text(self.titles[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Value: \(self.titles[self.selectedIndex])")
Spacer()
}
}
}
func setFrame(index: Int, frame: CGRect) {
self.frames[index] = frame
}
}
struct CustomSegmentedPickerView_Previews: PreviewProvider {
static var previews: some View {
CustomSegmentedPickerView()
}
}
try this:
struct ScrollText: View
{
#State var scrollText: Bool = false
var body: some View
{
let textWidth: CGFloat = 460
let titleWidth: CGFloat = 240.0
let titleHeight: CGFloat = 70.0
HStack
{
ScrollView(.horizontal)
{
Text("13. This is my very long text title for a tv show")
.frame(minWidth: titleWidth, minHeight: titleHeight, alignment: .center)
.offset(x: (titleWidth < textWidth) ? (scrollText ? (textWidth * -1) - (titleWidth / 2) : titleWidth ) : 0, y: 0)
.animation(Animation.linear(duration: 10).repeatForever(autoreverses: false), value: scrollText)
.onAppear {
self.scrollText.toggle()
}
}
}
.frame(maxWidth: titleWidth, alignment: .center)
}
}
Optional:
if you want to calculate the text width (and not use a constant) then you can use a GeometryReader (that reads the actual dimensions of rendered objects at real time)
or you can use UIKit to calculate the width (i use this method and it is very reliant and can achieve calculations before rendering has occurred)
add this extension to your code:
// String+sizeUsingFont.swift
import Foundation
import UIKit
import SwiftUI
extension String
{
func sizeUsingFont(fontSize: CGFloat, weight: Font.Weight) -> CGSize
{
var uiFontWeight = UIFont.Weight.regular
switch weight {
case Font.Weight.heavy:
uiFontWeight = UIFont.Weight.heavy
case Font.Weight.bold:
uiFontWeight = UIFont.Weight.bold
case Font.Weight.light:
uiFontWeight = UIFont.Weight.light
case Font.Weight.medium:
uiFontWeight = UIFont.Weight.medium
case Font.Weight.semibold:
uiFontWeight = UIFont.Weight.semibold
case Font.Weight.thin:
uiFontWeight = UIFont.Weight.thin
case Font.Weight.ultraLight:
uiFontWeight = UIFont.Weight.ultraLight
case Font.Weight.black:
uiFontWeight = UIFont.Weight.black
default:
uiFontWeight = UIFont.Weight.regular
}
let font = UIFont.systemFont(ofSize: fontSize, weight: uiFontWeight)
let fontAttributes = [NSAttributedString.Key.font: font]
return self.size(withAttributes: fontAttributes)
}
}
and use it like this:
let textWidth: CGFloat = "13. This is my very long text title for a tv show".sizeUsingFont(fontSize: 24, weight: Font.Weight.regular).width
of course put the text in a var and change the font size and weight to meet your needs
also if you are about to use this solution inside a button's label, i suggest putting the onAppear() code inside async call, see this answer:
aheze spot-on answer

Why does the interface twitch when changing the state variable?

Content view:
#State var login: String = ""
#State var password: String = ""
#State var isLoginTextViewActive = false
#State var isPasswordTextViewActive = false
VStack {
TextField("Login", text: $login, onEditingChanged: { isEditing in
print(isEditing)
self.isLoginTextViewActive = isEditing
}
)
TextField("Password", text: $password)
HorizontalLine(color: isLoginTextViewActive ? Color.yellow : Color.black, height: CGFloat(1))
}.frame(width: CGFloat(200), alignment: .center)
Line code:
struct HorizontalLineShape: Shape {
func path(in rect: CGRect) -> Path {
let fill = CGRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height)
var path = Path()
path.addRoundedRect(in: fill, cornerSize: CGSize(width: 2, height: 2))
return path
}
}
struct HorizontalLine: View {
private var color: Color? = nil
private var height: CGFloat = 1.0
init(color: Color, height: CGFloat = 1.0) {
self.color = color
self.height = height
}
var body: some View {
HorizontalLineShape().fill(self.color!).frame(minWidth: 0, maxWidth: .infinity, minHeight: height, maxHeight: height)
}
}
When you change the focus between "TextView", they begin to twitch as if due to a change in height. I checked, every time the line initializer is called, but what to do with it I do not know.
This happens because there is a bug with the font size in textfields currently. If you set your font size manually for example 20, it will work like you want.
var body: some View {
VStack {
TextField("Login", text: $login, onEditingChanged: { isEditing in
print(isEditing)
self.isLoginTextViewActive = isEditing
})
.font(.system(size: 20, weight: .medium, design: .default)) // SET SIZE
TextField("Password", text: $password)
.font(.system(size: 20, weight: .medium, design: .default)) // SET SIZE
Rectangle()
.frame(height: 1)
.foregroundColor(isLoginTextViewActive ? Color.yellow : Color.black)
}.frame(width: CGFloat(200), alignment: .center)
}

Resources