How to label axes in Swift Charts? - ios

I'm trying to add axis unit labels to a chart built with Swift Charts.
For example:
import SwiftUI
import Charts
struct ContentView: View {
struct ChartData: Identifiable {
var id: Double { xVal }
let xVal: Double
let yVal: Double
}
let data: [ChartData] = [
.init(xVal: -5, yVal: 5),
.init(xVal: 0, yVal: 10),
.init(xVal: 5, yVal: 20),
]
var body: some View {
Chart(data) {
LineMark(
x: .value("Position", $0.xVal),
y: .value("Height", $0.yVal)
)
}
.chartYAxis {
AxisMarks(position: .leading)
}
}
}
Chart output, with the desired labels annotated in red:
How can we create axis labels like those in red above - or otherwise add units/descriptions to our axes?
Edit: I finally found it, see my answer below.

This is the correct modifier:
Chart {
...
}
.chartXAxisLabel("Position (meters)")
You can also provide a custom view like this:
Chart {
...
}
.chartXAxisLabel(position: .bottom, alignment: .center) {
Text("Position (meters)")
}

One way to create those red labels is to use a combination of Text with VStack or HStack as needed. The text can be rotated using rotationEffect(). This is the original code with modifications:
struct ContentView: View {
struct ChartData: Identifiable {
var id: Double { xVal }
let xVal: Double
let yVal: Double
}
let data: [ChartData] = [
.init(xVal: -5, yVal: 5),
.init(xVal: 0, yVal: 10),
.init(xVal: 5, yVal: 20),
]
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text("Height(cm)")
.rotationEffect(Angle(degrees: -90))
.foregroundColor(.red)
VStack {
Chart(data) {
LineMark(
x: .value("Position", $0.xVal),
y: .value("Height", $0.yVal)
)
}
.chartYAxis {
AxisMarks(position: .leading)
}
Text("Position(meters)")
.foregroundColor(.red)
}
}
.frame(width: 350, height: 400, alignment: .center)
}
}
This is the resulting layout:
Use AxisMarks() to change the default axis marks including labels, grid lines and tick marks. Within AxisMarks individual axis labels can be assigned using AxisValueLabel(). Text() is formatted as an individual label for AxisValueLabel()'s content.
When AxisMarks is used, the default grid lines and tick marks disappear, so they have been added back using AxisGridLine() and AxisTick(). The following code should be added immediately following Chart{} and replacing the original .chartYAxis():
.chartXAxis {
AxisMarks(position: .bottom, values: .automatic) { value in
AxisGridLine(centered: true, stroke: StrokeStyle(dash: [1, 2]))
AxisTick(centered: true, stroke: StrokeStyle(dash: [1, 2]))
AxisValueLabel() {
if let intValue = value.as(Int.self) {
Text("\(intValue) m")
.font(.system(size: 10))
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading, values: .automatic) { value in
AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1))
AxisValueLabel() {
if let intValue = value.as(Int.self) {
Text("\(intValue) cm")
.font(.system(size: 10))
}
}
}
}
The chart with custom labels will look like this:

Related

SwiftUI Charts - Y Axis title is backwards

Consider the following SwiftUI Charts view:
struct ChartView: View {
var data: [DataPoint]
var body: some View {
Chart {
ForEach(data) { point in
LineMark (
x: .value("x", point.x),
y: .value("y", point.y)
)
}
.interpolationMethod(.cardinal)
}
.chartYAxis {
AxisMarks(position: .leading)
}
.chartXAxisLabel(position: .bottom, alignment: .center) {
Text("x title")
}
.chartYAxisLabel(position: .leading, alignment: .center) {
Text("y title")
}
.padding(10)
}
}
This is the result:
Note the Y axis title is backwards. How can I flip the y axis title 180 degrees?

How would I set each instance of a class to a specific page for a TabView

I am trying to assign each weather location in my app a page in a tab view but am unsure on how to achieve this.
I have a currentPage variable in my location view which should add 1 every time a weather location is shown on screen but instead it just keeps adding one throughout the entire foreach loop I have, so a page number isn't actually assigned to a weather view.
I was wondering if anyone knew how I could assign a page number to each location when iterating through my for each loop.
My location view is shown below. The code I tried is at the bottom in the onAppear modifier in the if statement but it doesn't work like I said above and I am not sure if this is even the right approach for doing something like this. Any help would be great!
struct LocationView: View {
#ObservedObject var weatherVM: WeatherViewViewModel
#ObservedObject var multiLocationVM: MultiLocationViewModel
#State private var pageSelection = 1
#State var currentPage: Int = 0
var body: some View {
ZStack {
LinearGradient(colors: [Color("BackgroundColorSun"), Color("BackgroundColorSky")], startPoint: .topLeading, endPoint: UnitPoint(x: 0.5, y: 0.5)).ignoresSafeArea()
if multiLocationVM.weatherVM.count == 0 {
BlankLocationView(multiLocationVM: multiLocationVM)
} else {
TabView(selection: $pageSelection) {
ForEach(multiLocationVM.weatherVM, id: \.self.city) { location in
VStack(spacing: 0) {
VStack(spacing: -10) {
ZStack(alignment: .center) {
Text("\(location.getWeatherIconFor(icon: location.weatherIcon))")
//.innerShadow(Color(.red))
.font(.custom("SF Pro Text", size: 64))
.innerShadow()
//.foregroundColor(.gray)
.offset(x: -70, y: -35)
Text("\(location.getTempFor(temp: location.weather.current.temp))°")
.font(.system(size: 96, weight: .semibold, design: .rounded))
.tracking(-0.41)
.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 4)
}
.offset(x: 15)
Text(location.conditions)
.font(.custom("SF Pro Display", size: 24))
}
.padding(EdgeInsets(top: -8, leading: 0, bottom: -8, trailing: 0))
VStack(spacing: -20) {
HourlyWeatherView(weatherVM: location)
DailyWeatherView(weatherVM: location)
}
Spacer()
}
.onAppear() {
if location.city != weatherVM.city {
weatherVM.city = location.city
currentPage += multiLocationVM.weatherVM.firstIndex(of: location)!
location.page = currentPage
print("current \(currentPage)")
print("Location \(location.page)")
} else {
return
}
}
.tag(currentPage)
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.indexViewStyle(.page(backgroundDisplayMode: .interactive))
}
}
.padding(.bottom, 70)
.ignoresSafeArea()
}
}
struct LocationView_Previews: PreviewProvider {
static var previews: some View {
LocationView(weatherVM: WeatherViewViewModel(city: "Phoenix"), multiLocationVM: MultiLocationViewModel())
}
}
IMHO you don't need the currentPage counter as TabView(selection: )is handling that for you. The selection, in your case pageSelection will update when the user swipes, or you can set it programmatically. You just have to provide a fitting .tag:
// dummy test data
struct Weather {
var city: String
var icon: String
var temp: Int
var condition: String
}
let multiLocationVM = [
Weather(city: "Vancouver", icon: "cloud", temp: 64, condition: " Mostly Cloudy"),
Weather(city: "New York", icon: "cloud.sun", temp: 78, condition: " Mostly Sunny"),
Weather(city: "Los Angeles", icon: "sun.max", temp: 91, condition: " Sunny"),
]
struct ContentView: View {
#State private var pageSelection = "Vancouver" // default here
#State var currentPage: Int = 0
var body: some View {
ZStack {
LinearGradient(colors: [Color(.yellow), Color(.blue)], startPoint: .topLeading, endPoint: UnitPoint(x: 0.5, y: 0.5)).ignoresSafeArea()
if multiLocationVM.count == 0 {
// BlankLocationView(multiLocationVM: multiLocationVM)
} else {
TabView(selection: $pageSelection) {
ForEach(multiLocationVM, id: \.city) { location in
LocalWeather(location: location)
.tag(location.city) // set tab id here
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
}
}
.padding(.bottom, 70)
.ignoresSafeArea()
}
}
struct LocalWeather: View {
let location: Weather
var body: some View {
VStack(alignment: .center, spacing: 0) {
VStack(alignment: .center, spacing: -10) {
ZStack(alignment: .center) {
Image(systemName: location.icon)
.font(.custom("SF Pro Text", size: 32))
.offset(x: -90, y: -35)
Text("\(location.temp)")
.font(.system(size: 96, weight: .semibold, design: .rounded))
.tracking(-0.41)
.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 4)
}
.offset(x: 15)
Text(location.condition)
.font(.custom("SF Pro Display", size: 24))
}
.padding(EdgeInsets(top: -8, leading: 0, bottom: -8, trailing: 0))
Spacer()
}
}
}

How to assign a variable in e.g. a ForEach in SwiftUI?

is it possible to set a variable in SwiftUI e.g. in a ForEach like this:
struct ContentView: View {
var test: Int
var body: some View {
List {
ForEach(1...5, id: \.self) {
Text("\($0)…")
test = $0 // how to realise this?
}
}
}
}
I can not bring this to live, I'm getting an error like:
Unable to infer complex closure return type; add explicit type to disambiguate
You cannot assign to test anything from anywhere inside your ContentView, because it is struct and, so, self is immutable, but you will be able to do something like in the following:
struct ContentView: View {
// var test: Int // cannot be assigned, because self is immutable
var body: some View {
List {
ForEach(1...5, id: \.self) { (i) -> Text in
let test = i // calculate something intermediate
return Text("\(test)…")
}
}
}
}
What you can do is using a #State var definition and apply the value from a task inside your foreach.
Example:
struct MyView: View {
#State var curGameDate: String = ""
var gameSection: GameSectionDto
var body: some View {
Text(curGameDate) // <- use the state var outside the loop
.padding(.bottom, 2.0)
.padding(.horizontal, 10.0)
.foregroundColor(Color("DarkGrey"))
.font(.system(size: 15.0))
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(gameSection.listItems, id: \.gameIdentifier) { game in
GeometryReader { proxy in
GameRowView(game: game, isResult: gameSection.result)
.background(RoundedCorners(color: .white, tl: 10, tr: 10, bl: 10, br: 10))
.padding([.leading, .trailing], 5.0)
.rotation3DEffect(.degrees(proxy.frame (in: .global).minX / -10), axis: (x: 0, y: 1, z: 0))
.shadow(color: .black.opacity(0.3),
radius: 8, x: 0, y: 6)
} //: GeoReader
.padding([.leading, .trailing], 5.0)
.padding(.top, 5.0)
.padding(.bottom, 25.0)
.task { // assign it in the foreach
self.curGameDate = game.gameDate.getDateString(pattern: "eeee, dd.MM.yyyy")
} //: task
} //: ForEach
}
}

Fill dynamically-sized Rectangle with a RadialGradient sized to fit perfectly using SwiftUI in Swift 5.1 for iOS

I've got a SwiftUI screen that displays data in a grid format, dynamically sizing the cells so that they always fill the width available, and making sure they are always squares.
I've got the layout working nicely, but the gradients are a little frustrating. When creating a RadialGradient, you need to specify beginRadius and endRadius in actual screen points, rather than unit points (from 0.0 to 1.0).
Like this:
RadialGradient(gradient: item.isBlue ? self.blue : self.red,
center: .center,
startRadius: 0.0,
endRadius: 100.0)
In the example above, I've picked an arbitrary size of 100 points for the endRadius. This works just fine, but really gives the squares a different look when they are different sizes. Check out the two examples below.
Example 1 -- 3x2 grid:
Example 2 -- 2x3 grid:
Here's all the code in an example project ContentView.swift which demonstrates clearly what I mean:
import SwiftUI
struct MyDataItem: Identifiable {
let id: UUID = UUID()
let text: String
let isBlue: Bool
}
struct MyDataRow: Identifiable {
let id: UUID = UUID()
let items: [MyDataItem]
}
struct ContentView: View {
#State private var datasetIndex: Int = 0
#State private var sets = [
// First data set
[
MyDataRow(items: [
MyDataItem(text: "A", isBlue: false),
MyDataItem(text: "B", isBlue: true),
MyDataItem(text: "C", isBlue: true),
]),
MyDataRow(items: [
MyDataItem(text: "D", isBlue: false),
MyDataItem(text: "E", isBlue: false),
MyDataItem(text: "F", isBlue: false),
]),
],
// Second data set
[
MyDataRow(items: [
MyDataItem(text: "A", isBlue: false),
MyDataItem(text: "B", isBlue: true),
]),
MyDataRow(items: [
MyDataItem(text: "C", isBlue: true),
MyDataItem(text: "D", isBlue: false),
]),
MyDataRow(items: [
MyDataItem(text: "E", isBlue: false),
MyDataItem(text: "F", isBlue: false),
]),
]
]
var body: some View {
VStack {
Picker(selection: $datasetIndex, label: Text("Pick a data set")) {
Text("Set 1").tag(0)
Text("Set 2").tag(1)
}.pickerStyle(SegmentedPickerStyle())
GridThing(rows: self.$sets[self.datasetIndex])
Spacer()
}
}
}
struct GridThing: View {
#Binding var rows: [MyDataRow]
private let spacing: CGFloat = 20.0
let blue = Gradient(colors: [Color.blue, Color(red: 0.85, green: 0.85, blue: 1.0)])
let red = Gradient(colors: [Color.red, Color(red: 1.0, green: 0.85, blue: 0.85)])
var body: some View {
VStack(alignment: .center, spacing: spacing) {
ForEach(self.rows) { row in
HStack(alignment: .top, spacing: self.spacing) {
ForEach(row.items) { item in
ZStack {
Rectangle()
.fill(RadialGradient(gradient: item.isBlue ? self.blue : self.red,
center: .center,
startRadius: 0.0,
endRadius: 100.0))
.aspectRatio(1.0, contentMode: .fit)
Text(item.text)
.foregroundColor(.black)
}
}
}
}
}
}
}
I want to have the RadialGradient use an endRadius that matches the height and width of the square.
I'd love it to be able to do this automatically. LinearGradient, for example, takes UnitPoint for startPoint and endPoint, which would be perfect, but for RadialGradient we need to do all the work I guess.
My main thought on this is to somehow incorporate a GeometryReader.
I've tried to wrap it like this, calculating an endRadius with the GeometryReader. This one fails because "the compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions":
var body: some View {
VStack(alignment: .center, spacing: spacing) {
GeometryReader { geom in
ForEach(self.rows) { row in
HStack(alignment: .top, spacing: self.spacing) {
ForEach(row.items) { item in
ZStack {
Rectangle()
.fill(RadialGradient(gradient: item.isBlue ? self.blue : self.red,
center: .center,
startRadius: 0.0,
endRadius: (geom.size.width - (self.spacing * (row.items.count + 1))) / row.items.count))
.aspectRatio(1.0, contentMode: .fit)
Text(item.text)
.foregroundColor(.black)
}
}
}
}
}
}
}
Thanks!
Geometry reader is correct, but instead of wrapping the whole grid, just wrapped square and then you don't need to do any extra computations: the geometry width is the width of the square and that's twice the radius of your gradient. here's a playground:
import SwiftUI
import PlaygroundSupport
struct V: View {
var body: some View {
VStack(alignment: .center, spacing: 10) {
ForEach(0..<4) { row in
HStack(alignment: .top, spacing: 10) {
ForEach(0..<4) { item in
ZStack {
GeometryReader { geometry in
Rectangle()
.fill(
RadialGradient(
gradient: Gradient(colors: [Color.white, Color.red, Color.green]),
center: .center,
startRadius: 0.0,
endRadius: geometry.size.width / 2 )
)
.aspectRatio(1.0, contentMode: .fit)
}
Text("text")
.foregroundColor(.black)
}
}
}
}
}
}
}
PlaygroundPage.current.setLiveView(V())

Performance issue when creating a list of 1000 elements in SwiftUI

I have a horizontal scroll view with lists. When scrolling horizontally, how to make the lists to snap to the edges.
struct RowView: View {
var post: Post
var body: some View {
GeometryReader { geometry in
VStack {
Text(self.post.title)
Text(self.post.description)
}.frame(width: geometry.size.width, height: 200)
//.border(Color(#colorLiteral(red: 0.1764705926, green: 0.01176470611, blue: 0.5607843399, alpha: 1)))
.background(Color(#colorLiteral(red: 0.721568644, green: 0.8862745166, blue: 0.5921568871, alpha: 1)))
.cornerRadius(10, antialiased: true)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}
}
struct ListView: View {
var n: Int
#State var posts = [Post(id: UUID(), title: "1", description: "11"),
Post(id: UUID(), title: "2", description: "22"),
Post(id: UUID(), title: "3", description: "33")]
var body: some View {
GeometryReader { geometry in
ScrollView {
ForEach(0..<self.n) { n in
RowView(post: self.posts[0])
//.border(Color(#colorLiteral(red: 0.8078431487, green: 0.02745098062, blue: 0.3333333433, alpha: 1)))
.frame(width: geometry.size.width, height: 200)
}
}
}
}
}
struct ContentView: View {
init() {
initGlobalStyles()
}
func initGlobalStyles() {
UITableView.appearance().separatorColor = .clear
}
var body: some View {
GeometryReader { geometry in
NavigationView {
ScrollView(.horizontal) {
HStack {
ForEach(0..<3) { _ in
ListView(n: 1000) // crashes
.frame(width: geometry.size.width - 60)
}
}.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 0))
}
}
}
}
}
When I give the value of ListView(n: 1000), the view is crashing. The app launches and a white screen is shown for some time and then I get a black screen.
2019-10-06 15:52:57.644766+0530 MyApp[12366:732544] [Render] CoreAnimation: Message::send_message() returned 0x1000000e
How to fix this? My assumption is that it would be using something like dequeue cells like UITableView, but not sure why it's crashing.
There are a couple of issues with the code provided. The most important is you are not using a List just a ForEach nested in a ScrollView, which is like equivalent of placing 1000 UIViews in a UIStack - not very efficient. There is also a lot of hardcoded dimensions and quite a few of them are duplicates but nevertheless add a significant burden when the views are calculated.
I have simplified quite a lot and it runs with n = 10000 without crashing:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
NavigationView {
ScrollView(.horizontal) {
HStack {
ForEach(0..<3) { _ in
ListView(n: 10000)
.frame(width: geometry.size.width - 60)
}
} .padding([.leading], 10)
}
}
}
}
}
struct ListView: View {
var n: Int
#State var posts = [Post(id: UUID(), title: "1", description: "11"),
Post(id: UUID(), title: "2", description: "22"),
Post(id: UUID(), title: "3", description: "33")]
var body: some View {
List(0..<self.n) { n in
RowView(post: self.posts[0])
.frame(height: 200)
}
}
}
struct RowView: View {
var post: Post
var body: some View {
HStack {
Spacer()
VStack {
Spacer()
Text(self.post.title)
Text(self.post.description)
Spacer()
}
Spacer()
} .background(RoundedRectangle(cornerRadius: 10)
.fill(Color(#colorLiteral(red: 0.721568644, green: 0.8862745166, blue: 0.5921568871, alpha: 1))))
}
}
ScrollView don't reuse anything. But List do.
so change this:
ScrollView {
ForEach(0..<self.n) { n in
,,,
}
}
to this:
List(0..<self.n) { n in
,,,
}
SwiftUI 2.0
You can use Lazy stacks like the LazyVStack and the LazyHStack. So even if you use them with ScrollView, It will be smooth and performant.
I've created SwiftUI horizontal list which loads views only for visible objects + extra elements as a buffer. Moreover, it exposes the offset parameter as binding so you can follow it or modify it from outside.
You can access source code here HList
Give it a go! This example is prepared in the swift playground.
Example use case
struct ContentView: View {
#State public var offset: CGFloat = 0
var body: some View {
HList(offset: self.$offset, numberOfItems: 10000, itemWidth: 80) { index in
Text("\(index)")
}
}
}
To see content being reused in action you could do something like this
struct ContentView: View {
#State public var offset: CGFloat = 0
var body: some View {
HList(offset: self.$offset, numberOfItems: 10000, itemWidth: 80) { index in
Text("\(index)")
}
.frame(width: 200, height: 60)
.border(Color.black, width: 2)
.clipped()
}
}
If you do remove .clipped() at the end you will see how the extra component is reused while scrolling when it moves out of the frame.
Update
While the above solution is still valid, however, it is a custom component.
With WWDC2020 SwiftUI introduces lazy components such as LazyHStack but keep in mind that:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
Meaning, elements are loaded lazy but after that, they are kept in the memory.
My custom HList only refers to visible components. Not the one which already has appeared.

Resources