Manipulation a private value of child view without accessing it directly - swift

I was looking a light approach to manipulation of a private value in child view without accessing that value directly or involving me with notification or ObservableObject, I came a cross to this approach, I am okay with the result, but I need to know any improvement or better approach.
struct ContentView: View {
#State private var reset: Bool = Bool()
var body: some View {
VStack {
CircleView(reset: reset)
Button("reset") { reset.toggle() }
}
}
}
struct CircleView: View {
let reset: Bool
#State private var size: CGFloat = 100.0
var body: some View {
Circle()
.frame(width: size, height: size)
.onTapGesture { size += 50.0 }
.onChange(of: reset, perform: { _ in size = 100.0 })
}
}
Update:
struct CircleView: View {
#Binding var reset: Bool
#State private var size: CGFloat = 100.0
var body: some View {
Circle()
.frame(width: size, height: size)
.onTapGesture { size += 50.0 }
.onChange(of: reset, perform: { newValue in
if (newValue) { size = 100.0; reset.toggle() }
})
}
}

Related

Conditional view modifier sometimes doesn't want to update

As I am working on a study app, I'm tring to build a set of cards, that a user can swipe each individual card, in this case a view, in a foreach loop and when flipped through all of them, it resets the cards to normal stack. The program works but sometimes the stack of cards doesn't reset. Each individual card updates a variable in a viewModel which my conditional view modifier looks at, to reset the stack of cards using offset and when condition is satisfied, the card view updates, while using ".onChange" to look for the change in the viewModel to then update the variable back to original state.
I've printed each variable at each step of the way and every variable updates and I can only assume that the way I'm updating my view, using conditional view modifier, may not be the correct way to go about. Any suggestions will be appreciated.
Here is my code:
The view that houses the card views with the conditional view modifier
extension View {
#ViewBuilder func `resetCards`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition == true {
transform(self).offset(x: 0, y: 0)
} else {
self
}
}
}
struct StudyListView: View {
#ObservedObject var currentStudySet: HomeViewModel
#ObservedObject var studyCards: StudyListViewModel = StudyListViewModel()
#State var studyItem: StudyModel
#State var index: Int
var body: some View {
ForEach(currentStudySet.allSets[index].studyItem.reversed()) { item in
StudyCardItemView(currentCard: studyCards, card: item, count: currentStudySet.allSets[index].studyItem.count)
.resetCards(studyCards.isDone) { view in
view
}
.onChange(of: studyCards.isDone, perform: { _ in
studyCards.isDone = false
})
}
}
}
StudyCardItemView
struct StudyCardItemView: View {
#StateObject var currentCard: StudyListViewModel
#State var card: StudyItemModel
#State var count: Int
#State var offset = CGSize.zero
var body: some View {
VStack{
VStack{
ZStack(alignment: .center){
Text("\(card.itemTitle)")
}
}
}
.frame(width: 350, height: 200)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(5)
.rotationEffect(.degrees(Double(offset.width / 5)))
.offset(x: offset.width * 5, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded{ _ in
if abs(offset.width) > 100 {
currentCard.cardsSortedThrough += 1
if (currentCard.cardsSortedThrough == count) {
currentCard.isDone = true
currentCard.cardsSortedThrough = 0
}
} else {
offset = .zero
}
}
)
}
}
HomeViewModel
class HomeViewModel: ObservableObject {
#Published var studySet: StudyModel = StudyModel()
#Published var allSets: [StudyModel] = [StudyModel()]
}
I initialize allSets with one StudyModel() to see it in the preview
StudyListViewModel
class StudyListViewModel: ObservableObject {
#Published var cardsSortedThrough: Int = 0
#Published var isDone: Bool = false
}
StudyModel
import SwiftUI
struct StudyModel: Hashable{
var title: String = ""
var days = ["One day", "Two days", "Three days", "Four days", "Five days", "Six days", "Seven days"]
var studyGoals = "One day"
var studyItem: [StudyItemModel] = []
}
Lastly, StudyItemModel
struct StudyItemModel: Hashable, Identifiable{
let id = UUID()
var itemTitle: String = ""
var itemDescription: String = ""
}
Once again, any help would be appreciated, thanks in advance!
I just found a fix and I put .onChange at the end for StudyCardItemView. Basically, the onChange helps the view scan for a change in currentCard.isDone variable every time it was called in the foreach loop and updates offset individuality. This made my conditional view modifier obsolete and just use the onChange to check for the condition.
I still used onChange outside the view with the foreach loop, just to set currentCard.isDone variable false because the variable will be set after all array elements are iterator through.
The updated code:
StudyCardItemView
struct StudyCardItemView: View {
#StateObject var currentCard: StudyListViewModel
#State var card: StudyItemModel
#State var count: Int
#State var offset = CGSize.zero
var body: some View {
VStack{
VStack{
ZStack(alignment: .center){
Text("\(card.itemTitle)")
}
}
}
.frame(width: 350, height: 200)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(5)
.rotationEffect(.degrees(Double(offset.width / 5)))
.offset(x: offset.width * 5, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded{ _ in
if abs(offset.width) > 100 {
currentCard.cardsSortedThrough += 1
if (currentCard.cardsSortedThrough == count) {
currentCard.isDone = true
currentCard.cardsSortedThrough = 0
}
} else {
offset = .zero
}
}
)
.onChange(of: currentCard.isDone, perform: {_ in
offset = .zero
})
}
}
StudyListView
struct StudyListView: View {
#ObservedObject var currentStudySet: HomeViewModel
#ObservedObject var studyCards: StudyListViewModel = StudyListViewModel()
#State var studyItem: StudyModel
#State var index: Int
var body: some View {
ForEach(currentStudySet.allSets[index].studyItem.reversed()) { item in
StudyCardItemView(currentCard: studyCards, card: item, count:
currentStudySet.allSets[index].studyItem.count)
.onChange(of: studyCards.isDone, perform: { _ in
studyCards.isDone = false
})
}
}
}
Hope this helps anyone in the future!

Make sheet the exact size of the content inside

Let's say I have a custom view inside of a sheet, something like this
VStack {
Text("Title")
Text("Some very long text ...")
}
.padding()
.presentationDetents([.height(250)])
How can I get the exact height of the VStack and pass it to the presentationDetents modifier so that the height of the sheet is exactly the height of the content inside?
You can use a GeometryReader and PreferenceKey to read the size and then write it to a state variable. In my example, I store the entire size, but you could adjust it to store just the height, since it's likely that that is the only parameter you need.
struct ContentView: View {
#State private var showSheet = false
#State private var size: CGSize = .zero
var body: some View {
Button("View sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
VStack {
Text("Title")
Text("Some very long text ...")
}
.padding()
.background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) { newSize in
size.height = newSize.height
}
.presentationDetents([.height(size.height)])
}
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}
Using the general idea made by #jnpdx including some updates such as reading the size of the overlay instead of the background, here is what works for me:
struct ContentView: View {
#State private var showSheet = false
#State private var sheetHeight: CGFloat = .zero
var body: some View {
Button("Open sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
VStack {
Text("Title")
Text("Some very long text ...")
}
.padding()
.overlay {
GeometryReader { geometry in
Color.clear.preference(key: InnerHeightPreferenceKey.self, value: geometry.size.height)
}
}
.onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in
sheetHeight = newHeight
}
.presentationDetents([.height(sheetHeight)])
}
}
}
struct InnerHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
More reuseable
struct InnerHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
extension View {
func fixedInnerHeight(_ sheetHeight: Binding<CGFloat>) -> some View {
padding()
.background {
GeometryReader { proxy in
Color.clear.preference(key: InnerHeightPreferenceKey.self, value: proxy.size.height)
}
}
.onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in sheetHeight.wrappedValue = newHeight }
.presentationDetents([.height(sheetHeight.wrappedValue)])
}
}
struct ExampleView: View {
#State private var showSheet = false
#State private var sheetHeight: CGFloat = .zero
var body: some View {
Button("Open sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
VStack {
Text("Title")
Text("Some very long text ...")
}
.fixedInnerHeight($sheetHeight)
}
}
}
struct ContentView: View {
#State private var showingSheet = false
let heights = stride(from: 0.1, through: 1.0, by: 0.1).map { PresentationDetent.fraction($0) }
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
Text("Random text ")
.presentationDetents(Set(heights))
}
}
}

Trigger a animation from view outside

I want to make an animation view, it will rotate when clicked.
like this:
struct AnimatedView: View {
#State var degree: Double = 0.0
var body: some View {
ZStack {
Image("picture")
.rotationEffect(.degrees(degree))
.animation(
Animation.linear(duration: 2)
)
Button(action: {
self.degree = 180.0
}) {
Text("animate").frame(width: 100, height: 100)
}
}
}
func active() {
self.degree = 180.0
}
}
I place this view in ContentView, and I want to active the animation from other button not in the view.
I found function active can't change degree value, so the animation not start.
I knew use #Blinding keep degree variable in ContentView may work, but I don't like too much variable in there.
As you said, you need to use a #Binding, i.e.
struct ContentView: View {
#State private var degree: Double = 0.0
var body: some View {
VStack {
AnimatedView(degree: $degree)
Button(action: {
self.degree += 180.0
}) {
Text("animate")
}
}
}
}
struct AnimatedView: View {
#Binding var degree: Double
var body: some View {
Image(systemName: "checkmark")
.rotationEffect(.degrees(degree))
.animation(
Animation.linear(duration: 2)
)
}
}

Is it possible to pass the values of GeometryReader to a #Observableobject

I need to do calculations based on the size of the device and the width of a screen.
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReader { geometry in
VStack{
TextField("Enter your name", text:self.$settings.translateString)
}
}
}
}
My ObservableObject can be seen below
class TranslationViewModel: ObservableObject {
#Published var translateString = ""
var ScreenSize : CGFloat = 0
var spacing : CGFloat = 4
var charSize : CGFloat = 20
init(spacing: CGFloat, charSize : CGFloat) {
self.spacing = spacing
self.charSize = charSize
}
}
I need a way to pass the geometry.size.width to my ScreenSize property but have no idea how to do this.
The simplest way is to have setter-method inside the ObservableObject which returns an EmptyView.
import SwiftUI
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReader { geometry in
VStack{
self.settings.passWidth(geometry: geometry)
TextField("Enter your name", text:self.$settings.translateString)
}
}
}
}
class TranslationViewModel: ObservableObject {
#Published var translateString = ""
var ScreenSize : CGFloat = 0
var spacing : CGFloat = 4
var charSize : CGFloat = 20
init(spacing: CGFloat, charSize : CGFloat) {
self.spacing = spacing
self.charSize = charSize
}
func passWidth(geometry: GeometryProxy) -> EmptyView {
self.ScreenSize = geometry.size.width
return EmptyView()
}
}
Then you could implement a wrapper around GeometryReader taking content: () -> Content and a closure which gets executed every time the GeometryReader gets rerendered where you can update whatever you wish.
import SwiftUI
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReaderEasy(callback: {
self.settings.ScreenSize = $0.size.width
}) { geometry in
TextField("Enter your name", text:self.$settings.translateString)
}
}
}
struct GeometryReaderEasy<Content: View>: View {
var callback: (GeometryProxy) -> ()
var content: (GeometryProxy) -> (Content)
private func setGeometry(geometry: GeometryProxy) -> EmptyView {
callback(geometry)
return EmptyView()
}
var body: some View {
GeometryReader { geometry in
VStack{
self.setGeometry(geometry: geometry)
self.content(geometry)
}
}
}
}
You can use a simple extension on View to allow arbitrary code execution when building your views.
extension View {
func execute(_ closure: () -> Void) -> Self {
closure()
return self
}
}
And then
var body: some View {
GeometryReader { proxy
Color.clear.execute {
self.myObject.useProxy(proxy)
}
}
}
Another option is to set the value using .onAppear
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReader { geometry in
VStack{
TextField("Enter your name", text:self.$settings.translateString)
} .onAppear {
settings.ScreenSize = geometry.size.width
}
}
}
}

Animate view on property change SwiftUI

I have a view
struct CellView: View {
#Binding var color: Int
#State var padding : Length = 10
let colors = [Color.yellow, Color.red, Color.blue, Color.green]
var body: some View {
colors[color]
.cornerRadius(20)
.padding(padding)
.animation(.spring())
}
}
And I want it to have padding animation when property color changes. I want to animate padding from 10 to 0.
I've tried to use onAppear
...onAppear {
self.padding = 0
}
But it work only once when view appears(as intended), and I want to do this each time when property color changes. Basically, each time color property changes, I want to animate padding from 10 to 0. Could you please tell if there is a way to do this?
As you noticed in the other answer, you cannot update state from within body. You also cannot use didSet on a #Binding (at least as of Beta 4) the way you can with #State.
The best solution I could come up with was to use a BindableObject and sink/onReceive in order to update padding on each color change. I also needed to add a delay in order for the padding animation to finish.
class IndexBinding: BindableObject {
let willChange = PassthroughSubject<Void, Never>()
var index: Int = 0 {
didSet {
self.willChange.send()
}
}
}
struct ParentView: View {
#State var index = IndexBinding()
var body: some View {
CellView(index: self.index)
.gesture(TapGesture().onEnded { _ in
self.index.index += 1
})
}
}
struct CellView: View {
#ObjectBinding var index: IndexBinding
#State private var padding: CGFloat = 0.0
var body: some View {
Color.red
.cornerRadius(20.0)
.padding(self.padding + 20.0)
.animation(.spring())
.onReceive(self.index.willChange) {
self.padding = 10.0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.padding = 0.0
}
}
}
}
This example doesn't animate in the Xcode canvas on Beta 4. Run it on the simulator or a device.
As of Xcode 12, Swift 5
One way to achieve the desired outcome could be to move the currently selected index into an ObservableObject.
final class CellViewModel: ObservableObject {
#Published var index: Int
init(index: Int = 0) {
self.index = index
}
}
Your CellView can then react to this change in index using the .onReceive(_:) modifier; accessing the Publisher provided by the #Published property wrapper using the $ prefix.
You can then use the closure provided by this modifier to update the padding and animate the change.
struct CellView: View {
#ObservedObject var viewModel: CellViewModel
#State private var padding : CGFloat = 10
let colors: [Color] = [.yellow, .red, .blue, .green]
var body: some View {
colors[viewModel.index]
.cornerRadius(20)
.padding(padding)
.onReceive(viewModel.$index) { _ in
padding = 10
withAnimation(.spring()) {
padding = 0
}
}
}
}
And here's an example parent view for demonstration:
struct ParentView: View {
let viewModel: CellViewModel
var body: some View {
VStack {
CellView(viewModel: viewModel)
.frame(width: 200, height: 200)
HStack {
ForEach(0..<4) { i in
Button(action: { viewModel.index = i }) {
Text("\(i)")
.padding()
.frame(maxWidth: .infinity)
.background(Color(.secondarySystemFill))
}
}
}
}
}
}
Note that the Parent does not need its viewModel property to be #ObservedObject here.
You could use Computed Properties to get this working. The code below is an example how it could be done.
import SwiftUI
struct ColorChanges: View {
#State var color: Float = 0
var body: some View {
VStack {
Slider(value: $color, from: 0, through: 3, by: 1)
CellView(color: Int(color))
}
}
}
struct CellView: View {
var color: Int
#State var colorOld: Int = 0
var padding: CGFloat {
if color != colorOld {
colorOld = color
return 40
} else {
return 0
}
}
let colors = [Color.yellow, Color.red, Color.blue, Color.green]
var body: some View {
colors[color]
.cornerRadius(20)
.padding(padding)
.animation(.spring())
}
}
Whenever there is a single incremental change in the color property this will toggle the padding between 10 and 0
padding = color % 2 == 0 ? 10 : 0