SwiftUI layout with GeometryReader inside ScrollView - layout

I'm trying to create a scrollable grid of items. I create a custom view called GridView which uses GeometryReader to divide the space into columns and rows in HStacks and VStacks. But for some reason, its size shrinks to almost nothing when inside a ScrollView. In the screenshot you see the GridView (reddish) and it's parent VStack (greenish) have shrunk. The items inside the grid are still visible, rendered outside its area, but things do not scroll properly.
Why is the GridView not the size required to contain its items? If it did, I think this UI would scroll properly.
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Text("Section 1")
GridView(columns: 2, items: [1, 3, 5, 7, 9, 11, 13, 15]) { num in
Text("Item \(num)")
}
.background(Color.red.opacity(0.2))
}.background(Color.green.opacity(0.2))
}.background(Color.blue.opacity(0.2))
}
}
struct GridView<Content>: View where Content: View {
var columns: Int
let items: [Int]
let content: (Int) -> Content
init(columns: Int, items: [Int], #ViewBuilder content: #escaping (Int) -> Content) {
self.columns = columns
self.items = items
self.content = content
}
var rowCount: Int {
let (q, r) = items.count.quotientAndRemainder(dividingBy: columns)
return q + (r == 0 ? 0 : 1)
}
func elementFor(_ r: Int, _ c: Int) -> Int? {
let i = r * columns + c
if i >= items.count { return nil }
return items[i]
}
var body: some View {
GeometryReader { geo in
VStack {
ForEach(0..<self.rowCount) { ri in
HStack {
ForEach(0..<self.columns) { ci in
Group {
if self.elementFor(ri, ci) != nil {
self.content(self.elementFor(ri, ci)!)
.frame(width: geo.size.width / CGFloat(self.columns),
height: geo.size.width / CGFloat(self.columns))
} else {
Text("")
}
}
}
}
}
}
}
}
}

GeometryReader is a container view that defines its content as a function of its own size and coordinate space. Returns a flexible preferred size to its
parent layout.
in
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Text("Section 1")
GridView(columns: 2, items: [1, 3, 5, 7, 9, 11, 13, 15]) { num in
Text("Item \(num)")
}
.background(Color.red.opacity(0.5))
}.background(Color.green.opacity(0.2))
}.background(Color.blue.opacity(0.2))
}
}
What is the height of GridView?
Hm ... number of lines multiplied by height of grid cell, which is height of GridView divided by number of lines ...
The equation has no solution :-)
Yes I can see, you defined height of grid cell as width of GridView divided by number of columns, but SwiftUI is not so clever.
You have to calculate the height of the GridView. I fixed number of rows to 4 to simplify it ...
struct ContentView: View {
var body: some View {
GeometryReader { g in
ScrollView {
VStack {
Text("Section 1")
GridView(columns: 2, items: [1, 3, 5, 7, 9, 11, 13, 15]) { num in
Text("Item \(num)")
}.frame(height: g.size.width / 2 * CGFloat(4))
.background(Color.red.opacity(0.5))
}.background(Color.green.opacity(0.2))
}.background(Color.blue.opacity(0.2))
}
}
}
You better remove the geometry reader and grid cell frame from GridView.body and set the size of cell in your content view.
struct ContentView: View {
var body: some View {
GeometryReader { g in
ScrollView {
VStack {
Text("Section 1")
GridView(columns: 2, items: [1, 3, 5, 7, 9, 11, 13, 15]) { num in
Text("Item \(num)").frame(width: g.size.width / 2, height: g.size .width / 2)
}
.background(Color.red.opacity(0.5))
}.background(Color.green.opacity(0.2))
}.background(Color.blue.opacity(0.2))
}
}
}
As you can see, there is no need to define height of GridView, the equation has solution now.

Related

How to scale multiline text by gesture in SwiftUI

I need to implement multiline text scaling by gesture. I tried to use scaleEffect() but it's become blurry and pixelized (first gif). Then I tried to change font directly from gesture but faced another problem: text starts jumping, changing line breaks and etc (second gif). fixedSize() can't solve this because it's multiline text.
I need to implement scaling like with scaleEffect() but without blurring like with scaling font directly. Here's my code:
struct ContentView: View {
#State var activeTextScale: CGFloat = 1.0
#State var lastScale: CGFloat = 1.0
#State var fontSize: CGFloat = 24
var body: some View {
ZStack {
ZStack {
Text(
"Abcdefghijklmnopqrstuvwxyz Abcdefghijklmnopqrstuvwxyz Abcdefghijklmnopqrstuvwxyz"
)
.foregroundColor(.yellow)
// .scaleEffect(activeTextScale)
.font(.system(size: fontSize * activeTextScale))
.background(
Rectangle()
.foregroundColor(.brown.opacity(0.6))
// .scaleEffect(activeTextScale)
)
.gesture(
MagnificationGesture()
.onChanged { value in
activeTextScale = value + lastScale
}
.onEnded { value in
lastScale = activeTextScale
}
)
}
}
}
}
Here I'm tried to scale font and view accordingly, but it gives the same result as on second gif:
struct ContentView: View {
#State var activeTextScale: CGFloat = 1.0
#State var lastScale: CGFloat = 1.0
#State var fontSize: CGFloat = 24
func getTextFontSize() -> CGFloat {
let size = fontSize * activeTextScale
print(activeTextScale)
print(size.rounded())
return size.rounded()
}
var body: some View {
ZStack {
VStack {
Text(
"Abcdefghijklmnopqrstuvwxyz Abcdefghijklmnopqrstuvwxyz Abcdefghijklmnopqrstuvwxyz"
)
.foregroundColor(.yellow)
.font(.system(size: getTextFontSize()))
.background(
Rectangle()
.foregroundColor(.brown.opacity(0.6))
)
}
.scaleEffect(activeTextScale)
.gesture(
MagnificationGesture()
.onChanged { value in
activeTextScale = value + lastScale
}
.onEnded { value in
lastScale = activeTextScale
}
)
}
}
}
First gif with scaleEffect()
Second gif with font scaling
Third gif with scaling view and font accordingly

Core Data, Problem with updating (duplicating instead)

I am new to Swift UI. Could you please help me with core data updating?
Here is the point of a problem:
I am building a WatchOS app. There are 3 Views there:
FirstView - a view with a button to Add a new Goal and a List of added Goals.
AddGoalView - appears after pressing Add new Goal.
RingView - a view with a Goal Ring (similar to activity ring mechanics) and all the data presented.
The point of the problem is the next:
After adding a new Goal everything is alright. The Data passes correctly from AddGoalView to the FirstView. I need only 2 items to be passed out of AddGoalView (One String and one Double).
Then, after pressing on the recently created Goal I appear on the Ring View. I successfully pass there 2 items (that String and Double I mentioned).
On the RingView I want to update the 3-rd Item (double) and send it back. So it can be updated on the FirstView.
the Result:
Instead of updating this 3-d Item it just seems to create a completely new Goal on the First View below the previous Goal. Photo
My Code (FirstView):
struct FirstView: View {
#FetchRequest (
entity:NewGoal.entity(),
sortDescriptors:[NSSortDescriptor(keyPath: \NewGoal.dateAdded, ascending: false)],
animation: .easeInOut )
var results:FetchedResults<NewGoal>
#State var showMe = false
var body: some View {
ScrollView{
VStack{
VStack(alignment: .leading){
Text("My Goals:")
NavigationLink(
destination: AddGoalView(),
isActive: $showMe,
label: {
Image(systemName: "plus")
Text("Set Money Goal")
})
Text("Recents:")
ForEach(results){ item in
VStack(alignment: .leading){
NavigationLink(
destination: RingView(GTitle: item.goalTitle ?? "", Sum: item.neededSum),
label: {
HStack{
Image(systemName: "gear")
VStack(alignment: .leading){
Text(item.goalTitle ?? "")
HStack{
Text("$\(item.yourSum, specifier: "%.f")") ///This item doesn't update
Text("/ $\(item.neededSum, specifier: "%.f")")
}
}
}
})
}
}
}
}
}
}
}
My Code (AddGoalView):
struct AddGoalView: View {
#State private var goalTitle = ""
#State private var showMe:Bool = true
#State private var neededSum:Double = 0.0
#State private var isFocusedNum = false
#Environment(\.managedObjectContext) var context
#Environment(\.presentationMode) var presentationMode
var body: some View {
ScrollView{
VStack (alignment: .leading, spacing: 6){
TextField("Goal Name...", text: $goalTitle)
HStack{
Text("$\(neededSum, specifier: "%.f")")
.overlay(
RoundedRectangle(cornerRadius: 9)
.stroke(isFocusedNum ? Color.red : Color.white, lineWidth: 1)
.opacity(1.0))
.focusable(true) { newState in isFocusedNum = newState}
.animation(.easeInOut(duration: 0.1), value: isFocusedNum)
.digitalCrownRotation(
$neededSum,
from: 0,
through: 100000,
by: 25,
sensitivity: .high)
}
Button(action: addGoal) {
Text("Add Goal")
}
.disabled(neededSum == 0.0)
.disabled(goalTitle == "")
.navigationTitle("Edit")
}
}
}
private func addGoal(){
let goal = NewGoal(context: context)
goal.goalTitle = goalTitle
goal.dateAdded = Date()
goal.neededSum = neededSum
do{
try context.save()
presentationMode.wrappedValue.dismiss()
}catch let err{
print(err.localizedDescription)
}
}
My Code (RingView Code):
struct RingView: View {
#State private var isFocusedSum = false
#State private var yournewSum:Double = 0.0
var goalItem: NewGoal?
var Sum:Double
var GTitle:String
#Environment(\.managedObjectContext) var context
#Environment(\.presentationMode) var presentationMode
#FetchRequest var results: FetchedResults<NewGoal>
init(GTitle: String, Sum: Double){
self.GTitle = GTitle
self.Sum = Sum
let predicate = NSPredicate(format:"goalTitle == %#", GTitle)
self._results=FetchRequest(
entity: NewGoal.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \NewGoal.dateAdded, ascending: false)],
predicate: predicate,
animation: .easeInOut
)
}
var body: some View {
ZStack{
ForEach(results) { item in
RingShape(percent:(yournewSum/item.neededSum*100), startAngle: -90, drawnClockwise: false) /// Ring
.stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round))
.fill(AngularGradient(gradient: Gradient(colors: [.red, .pink, .red]), center: .center))
.frame(width: 155, height: 155)
HStack(alignment: .top){
Spacer()
Button(action: addSum) { ///BUTTON TO Update
Image(systemName: "gear")
}
.clipShape(Circle())
}
VStack(alignment: .trailing, spacing: 0.0){
Spacer()
Text("$\(yournewSum, specifier: "%.f")") /// Here is the data I want to change via Digital Crown and update
.font(.title3)
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(Color.white, lineWidth: 2)
.opacity(isFocusedSum ? 1.0:0.0)
)
.focusable(true) { newState in isFocusedSum = newState}
.animation(.easeInOut(duration: 0.3), value: isFocusedSum)
.digitalCrownRotation(
$yournewSum,
from: 0,
through: Double((item.neededSum)),
by: 10,
sensitivity: .high)
Text("/ $\(item.neededSum, specifier: "%.f")") ///Here is the Double data I entered in AddGoalView
.font(.caption)
}
.frame(width: 200, height: 230)
.padding(.top, 7)
VStack(alignment: .center, spacing: 1.0){
Text(item.goalTitle ?? "Your Goal Name") ///Here is the String data I entered in AddGoalView
.foregroundColor(.gray)
}
.padding(.top, 200.0)
}
}
.padding([.top, .leading, .trailing], 5.0)
}
private func addSum(){
let goal = goalItem == nil ? NewGoal(context: context): goalItem
goal?.yourSum = yournewSum //// I am trying to update the Data here, but after running the func it creates a duplicate.
do{
try context.save()
presentationMode.wrappedValue.dismiss()
} catch let err{
print(err.localizedDescription)
}
}
You never give var goalItem: NewGoal? the initial value of the item you want to update.
try replacing this
RingView(GTitle: item.goalTitle ?? "", Sum: item.neededSum)
with
RingView(goalItem: item, GTitle: item.goalTitle ?? "", Sum: item.neededSum)
and of course your have to change your initializer for RingView to
init(goalItem: NewGoal? = nil, GTitle: String, Sum: Double){
and add to the initializer this line
self.goalItem = goalItem

Make TextEditor dynamic height SwiftUI

I'm trying to create a growing TextEditor as input for a chat view.
The goal is to have a box which expands until 6 lines are reached for example. After that it should be scrollable.
I already managed to do this with strings, which contain line breaks \n.
TextEditor(text: $composedMessage)
.onChange(of: self.composedMessage, perform: { value in
withAnimation(.easeInOut(duration: 0.1), {
if (value.numberOfLines() < 6) {
height = startHeight + CGFloat((value.numberOfLines() * 20))
}
if value.numberOfLines() == 0 || value.isEmpty {
height = 50
}
})
})
I created a string extension which returns the number of line breaks by calling string.numberOfLines() var startHeight: CGFloat = 50
The problem: If I paste a text which contains a really long text, it's not expanding when this string has no line breaks. The text get's broken in the TextEditor.
How can I count the number of breaks the TextEditor makes and put a new line character at that position?
Here's a solution adapted from question and answer,
struct ChatView: View {
#State var text: String = ""
// initial height
#State var height: CGFloat = 30
var body: some View {
ZStack(alignment: .topLeading) {
Text("Placeholder")
.foregroundColor(.appLightGray)
.font(Font.custom(CustomFont.sofiaProMedium, size: 13.5))
.padding(.horizontal, 4)
.padding(.vertical, 9)
.opacity(text.isEmpty ? 1 : 0)
TextEditor(text: $text)
.foregroundColor(.appBlack)
.font(Font.custom(CustomFont.sofiaProMedium, size: 14))
.frame(height: height)
.opacity(text.isEmpty ? 0.25 : 1)
.onChange(of: self.text, perform: { value in
withAnimation(.easeInOut(duration: 0.1), {
if (value.numberOfLines() < 6) {
// new height
height = 120
}
if value.numberOfLines() == 0 || value.isEmpty {
// initial height
height = 30
}
})
})
}
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.appLightGray, lineWidth: 0.5)
)
}
}
And the extension,
extension String {
func numberOfLines() -> Int {
return self.numberOfOccurrencesOf(string: "\n") + 1
}
func numberOfOccurrencesOf(string: String) -> Int {
return self.components(separatedBy:string).count - 1
}
}
I found a solution!
For everyone else trying to solve this:
I added a Text with the same width of the input field and then used a GeometryReader to calculate the height of the Text which automatically wraps. Then if you divide the height by the font size you get the number of lines.
You can make the text field hidden (tested in iOS 14 and iOS 15 beta 3)
If you're looking for an iOS 15 solution, I spent a while and figured it out. I didn't want to have to resort to UIKit or ZStacks with overlays or duplicative content as a "hack". I wanted it to be pure SwiftUI.
I ended up creating a separate struct that I could reuse anywhere I needed it, as well as add additional parameters in my various views.
Here's the struct:
struct FieldMultiEntryTextDynamic: View {
var text: Binding<String>
var body: some View {
TextEditor(text: text)
.padding(.vertical, -8)
.padding(.horizontal, -4)
.frame(minHeight: 0, maxHeight: 300)
.font(.custom("HelveticaNeue", size: 17, relativeTo: .headline))
.foregroundColor(.primary)
.dynamicTypeSize(.medium ... .xxLarge)
.fixedSize(horizontal: false, vertical: true)
} // End Var Body
} // End Struct
The cool thing about this is that you can have placeholder text via an if statement and it supports dynamic type sizes.
You can implement it as follows:
struct MyView: View {
#FocusState private var isFocused: Bool
#State private var myName: String = ""
var body: some View {
HStack(alignment: .top) {
Text("Name:")
ZStack(alignment: .trailing) {
if myName.isEmpty && !isFocused {
Text("Type Your Name")
.font(.custom("HelveticaNeue", size: 17, relativeTo: .headline))
.foregroundColor(.secondary)
}
HStack {
VStack(alignment: .trailing, spacing: 5) {
FieldMultiEntryTextDynamic(text: $myName)
.multilineTextAlignment(.trailing)
.keyboardType(.alphabet)
.focused($isFocused)
}
}
}
}
.padding()
.background(.blue)
}
}
Hope it helps!

Core Data Count SwiftUI

When I add a new reminder to my list I can not see the count + 1 simultaneously. But when I re-run the program I see the count is correct.
https://vimeo.com/545025225
struct ListCell: View {
var list : CDListModel
#State var count = 0
#State var isSelected: Bool = false
var body: some View {
HStack{
Color(list.color ?? "")
.frame(width: 30, height: 30, alignment: .center)
.cornerRadius(15)
Text(list.text ?? "")
.foregroundColor(.black)
.font(.system(size: 20, weight: .regular, design: .rounded))
Spacer()
Text(String(count))
.foregroundColor(.gray)
.onAppear{
DispatchQueue.main.async {
self.count = list.reminders!.count
}
}
}
}
}
If CDListModel is a CoreData entity, then you can just add this:
#ObservedObject var list : CDListModel
Also remove the State for the count.
Then display the count like this:
Text(String(list.reminders!.count))
As hint: I wouldn't use force unwrapping aswell. Maybe provide a default value instead of force unwrapping

CPoint positioning SwiftUI

I am drawing a line with Path, and on that line I want to have a circle if I play around I can get the circle on the line of course. However I do not understand why this code does not put the circle on the line:
struct CircleOnLineView: View {
func createCirle() -> some View {
return Circle().fill(Color.blue)
}
var body: some View {
GeometryReader { geometry in
ZStack {
Path { path in
path.move(to: CGPoint(x: 0, y: geometry.size.height / 2))
path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height / 2))
}
.stroke(Color.gray, lineWidth: 3)
Circle()
.fill(Color.blue)
.position(CGPoint(x: 0 , y: geometry.size.height / 2))
.frame(width: 5, height: 5)
}
}
}
}
Order of modifiers in this case is important. Here is how it is expected (1st - made size of shape, 2nd - position it):
Circle()
.fill(Color.blue)
.frame(width: 5, height: 5)
.position(CGPoint(x: 0 , y: geometry.size.height / 2))

Resources