SwiftUI - Autoscroll horizontal scrollview to show full text label - ios

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

Related

Custom picker switching views in SwiftUI

I am developing an app with a custom dock and I am unsure of how to change the view when highlighted on an item in the dock. I just couldn't find a way to switch the view when you highlight the item. I have attempted methods such as a switch statement but that did not work in my scenario. I have also attempted to use an if-else statement but that also did not work. I would much appreciate your help in finding a solution to this issue. Please review my code below...
struct MathematicallyController: View {
#State var selection: Int = 1
var body: some View {
ZStack {
ZStack {
if selection == 0 {
//view 1
} else if selection == 1 {
//view 2
} else if selection == 2 {
//view 3
} else {
//view 1
}
}
.overlay(
VStack {
Spacer()
ZStack {
BlurView(style: .systemThinMaterialDark)
.frame(maxWidth: .infinity, maxHeight: 65)
.cornerRadius(20)
.padding()
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 0.9)
.frame(maxWidth: .infinity, maxHeight: 65)
.blur(radius: 2)
.padding()
Picker(selection: selection)
.padding(5)
}
.offset(y: 30)
}
)
}
}
}
Picker
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct BackgroundGeometryReader: View {
var body: some View {
GeometryReader { geometry in
return Color
.clear
.preference(key: SizePreferenceKey.self, value: geometry.size)
}
}
}
struct SizeAwareViewModifier: ViewModifier {
#Binding private var viewSize: CGSize
init(viewSize: Binding<CGSize>) {
self._viewSize = viewSize
}
func body(content: Content) -> some View {
content
.background(BackgroundGeometryReader())
.onPreferenceChange(SizePreferenceKey.self, perform: { if self.viewSize != $0 { self.viewSize = $0 }})
}
}
struct SegmentedPicker: View {
private static let ActiveSegmentColor: Color = Color(.tertiarySystemBackground)
private static let BackgroundColor: Color = Color(.secondarySystemBackground)
private static let ShadowColor: Color = Color.white.opacity(0.2)
private static let TextColor: Color = Color(.secondaryLabel)
private static let SelectedTextColor: Color = Color(.label)
private static let TextFont: Font = .system(size: 12)
private static let SegmentCornerRadius: CGFloat = 12
private static let ShadowRadius: CGFloat = 10
private static let SegmentXPadding: CGFloat = 16
private static let SegmentYPadding: CGFloat = 9
private static let PickerPadding: CGFloat = 7
private static let AnimationDuration: Double = 0.2
// Stores the size of a segment, used to create the active segment rect
#State private var segmentSize: CGSize = .zero
// Rounded rectangle to denote active segment
private var activeSegmentView: AnyView {
// Don't show the active segment until we have initialized the view
// This is required for `.animation()` to display properly, otherwise the animation will fire on init
let isInitialized: Bool = segmentSize != .zero
if !isInitialized { return EmptyView().eraseToAnyView() }
return
RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius)
.fill(.regularMaterial)
.shadow(color: SegmentedPicker.ShadowColor, radius: SegmentedPicker.ShadowRadius)
.frame(width: self.segmentSize.width, height: self.segmentSize.height)
.offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0)
.animation(Animation.linear(duration: SegmentedPicker.AnimationDuration))
.overlay(
RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius)
.stroke(lineWidth: 1)
.shadow(color: SegmentedPicker.ShadowColor, radius: SegmentedPicker.ShadowRadius)
.frame(width: self.segmentSize.width, height: self.segmentSize.height)
.offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0)
.animation(Animation.linear(duration: SegmentedPicker.AnimationDuration))
)
.eraseToAnyView()
}
#Binding private var selection: Int
private let items: [Image]
init(items: [Image], selection: Binding<Int>) {
self._selection = selection
self.items = items
}
var body: some View {
// Align the ZStack to the leading edge to make calculating offset on activeSegmentView easier
ZStack(alignment: .leading) {
// activeSegmentView indicates the current selection
self.activeSegmentView
HStack {
ForEach(0..<self.items.count, id: \.self) { index in
self.getSegmentView(for: index)
}
}
}
.padding(SegmentedPicker.PickerPadding)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius))
}
// Helper method to compute the offset based on the selected index
private func computeActiveSegmentHorizontalOffset() -> CGFloat {
CGFloat(self.selection) * (self.segmentSize.width + SegmentedPicker.SegmentXPadding / 2)
}
// Gets text view for the segment
private func getSegmentView(for index: Int) -> some View {
guard index < self.items.count else {
return EmptyView().eraseToAnyView()
}
let isSelected = self.selection == index
return
Text(self.items[index])
// Dark test for selected segment
.foregroundColor(isSelected ? SegmentedPicker.SelectedTextColor: SegmentedPicker.TextColor)
.lineLimit(1)
.padding(.vertical, SegmentedPicker.SegmentYPadding)
.padding(.horizontal, SegmentedPicker.SegmentXPadding)
.frame(minWidth: 0, maxWidth: .infinity)
// Watch for the size of the
.modifier(SizeAwareViewModifier(viewSize: self.$segmentSize))
.onTapGesture { self.onItemTap(index: index) }
.eraseToAnyView()
}
// On tap to change the selection
private func onItemTap(index: Int) {
guard index < self.items.count else {
return
}
self.selection = index
}
}
struct Picker: View {
#State var selection: Int = 1
private let items: [Image] = [Image(systemName: "rectangle.on.rectangle"), Image(systemName: "timelapse"), Image(systemName: "plus")]
var body: some View {
SegmentedPicker(items: self.items, selection: self.$selection)
.padding()
}
}
In your Picker struct you are getting selection as a value not a Binding.
The purpose of using a Binding variable is to make the parent of the passed variable listen to the changes made in the struct. In other words, it binds the 2 views/values.
So what you should do is modify Picker like this:
struct Picker: View {
#Binding var selection: Int = 1
private let items: [Image] = [Image(systemName: "rectangle.on.rectangle"), Image(systemName: "timelapse"), Image(systemName: "plus")]
var body: some View {
SegmentedPicker(items: self.items, selection: self.$selection)
.padding()
}
}
And in MathematicallyController change Picker(selection: selection) into Picker(selection: $selection) so you'd have:
struct MathematicallyController: View {
#State var selection: Int = 1
var body: some View {
ZStack {
ZStack {
if selection == 0 {
//view 1
} else if selection == 1 {
//view 2
} else if selection == 2 {
//view 3
} else {
//view 1
}
}
.overlay(
VStack {
Spacer()
ZStack {
BlurView(style: .systemThinMaterialDark)
.frame(maxWidth: .infinity, maxHeight: 65)
.cornerRadius(20)
.padding()
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 0.9)
.frame(maxWidth: .infinity, maxHeight: 65)
.blur(radius: 2)
.padding()
Picker(selection: $selection)
.padding(5)
}
.offset(y: 30)
}
)
}
}
}
Also Note that a switch statement will work just as fine as an if one.

Fill remaining whitespace in text line with dots (multiline text) 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)

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 Create a Custom Segmented Control also in a ScrollView

Below is my code to create a standard segmented control.
struct ContentView: View {
#State private var favoriteColor = 0
var colors = ["Red", "Green", "Blue"]
var body: some View {
VStack {
Picker(selection: $favoriteColor, label: Text("What is your favorite color?")) {
ForEach(0..<colors.count) { index in
Text(self.colors[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Value: \(colors[favoriteColor])")
}
}
}
My question is how could I modify it to have a customized segmented control where I can have the boarder rounded along with my own colors, as it was somewhat easy to do with UIKit? Has any one done this yet.
I prefect example is the Uber eats app, when you select a restaurant you can scroll to the particular portion of the menu by selecting an option in the customized segmented control.
Included are the elements I'm looking to have customized:
* UPDATE *
Image of the final design
Is this what you are looking for?
import SwiftUI
struct CustomSegmentedPickerView: View {
#State private var selectedIndex = 0
private var titles = ["Round Trip", "One Way", "Multi-City"]
private var colors = [Color.red, Color.green, Color.blue]
#State private var frames = Array<CGRect>(repeating: .zero, count: 3)
var body: some View {
VStack {
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()
}
}
If I'm following the question aright the starting point might be something like the code below. The styling, clearly, needs a bit of attention. This has a hard-wired width for segments. To be more flexible you'd need to use a Geometry Reader to measure what was available and divide up the space.
struct ContentView: View {
#State var selection = 0
var body: some View {
let item1 = SegmentItem(title: "Some Way", color: Color.blue, selectionIndex: 0)
let item2 = SegmentItem(title: "Round Zip", color: Color.red, selectionIndex: 1)
let item3 = SegmentItem(title: "Multi-City", color: Color.green, selectionIndex: 2)
return VStack() {
Spacer()
Text("Selected Item: \(selection)")
SegmentControl(selection: $selection, items: [item1, item2, item3])
Spacer()
}
}
}
struct SegmentControl : View {
#Binding var selection : Int
var items : [SegmentItem]
var body : some View {
let width : CGFloat = 110.0
return HStack(spacing: 5) {
ForEach (items, id: \.self) { item in
SegmentButton(text: item.title, width: width, color: item.color, selectionIndex: item.selectionIndex, selection: self.$selection)
}
}.font(.body)
.padding(5)
.background(Color.gray)
.cornerRadius(10.0)
}
}
struct SegmentButton : View {
var text : String
var width : CGFloat
var color : Color
var selectionIndex = 0
#Binding var selection : Int
var body : some View {
let label = Text(text)
.padding(5)
.frame(width: width)
.background(color).opacity(selection == selectionIndex ? 1.0 : 0.5)
.cornerRadius(10.0)
.foregroundColor(Color.white)
.font(Font.body.weight(selection == selectionIndex ? .bold : .regular))
return Button(action: { self.selection = self.selectionIndex }) { label }
}
}
struct SegmentItem : Hashable {
var title : String = ""
var color : Color = Color.white
var selectionIndex = 0
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
None of the above solutions worked for me as the GeometryReader returns different values once placed in a Navigation View that throws off the positioning of the active indicator in the background. I found alternate solutions, but they only worked with fixed length menu strings. Perhaps there is a simple modification to make the above code contributions work, and if so, I would be eager to read it. If you're having the same issues I was, then this may work for you instead.
Thanks to inspiration from a Reddit user "End3r117" and this SwiftWithMajid article, https://swiftwithmajid.com/2020/01/15/the-magic-of-view-preferences-in-swiftui/, I was able to craft a solution. This works either inside or outside of a NavigationView and accepts menu items of various lengths.
struct SegmentMenuPicker: View {
var titles: [String]
var color: Color
#State private var selectedIndex = 0
#State private var frames = Array<CGRect>(repeating: .zero, count: 5)
var body: some View {
VStack {
ZStack {
HStack(spacing: 10) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: {
print("button\(index) pressed")
self.selectedIndex = index
}) {
Text(self.titles[index])
.foregroundColor(color)
.font(.footnote)
.fontWeight(.semibold)
}
.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
.modifier(FrameModifier())
.onPreferenceChange(FramePreferenceKey.self) { self.frames[index] = $0 }
}
}
.background(
Rectangle()
.fill(self.color.opacity(0.4))
.frame(
width: self.frames[self.selectedIndex].width,
height: 2,
alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX, y: self.frames[self.selectedIndex].height)
, alignment: .leading
)
}
.padding(.bottom, 15)
.animation(.easeIn(duration: 0.2))
Text("Value: \(self.titles[self.selectedIndex])")
Spacer()
}
}
}
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct FrameModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
}
}
func body(content: Content) -> some View {
content.background(sizeView)
}
}
struct NewPicker_Previews: PreviewProvider {
static var previews: some View {
VStack {
SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.blue)
NavigationView {
SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.red)
}
}
}
}

Resources