I am building a card game (Set) and when dealing the cards I am currently using
withAnimation{} and .transition(.move(edge: .bottom)
to have the cards animate from the bottom edge of the screen when the user taps the deal button. I want to make it so that the cards 'fly out' of the button and was wondering how I find the location of the button.
Once I find the position of the button I intend to use .offset to have them 'fly' out. Is there a better way?
I know it is possible using geometry reader, but I have not found out how to do so.
Here is my current code
struct SetGameView: View {
#ObservedObject var viewModel: SetViewModel = SetViewModel()
var location: (CGFloat, CGFloat) = (0.0, 0.0)
var body: some View {
VStack {
HStack {
Label("Score: \(viewModel.score)", systemImage: "face.dashed").font(.title).padding(.leading)
Spacer()
}
Grid(viewModel.dealtCards) { card in
CardView(card: card)
.transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .leading)))
.padding()
.onTapGesture { withAnimation { viewModel.choose(card) } }
} .onAppear { withAnimation(.easeInOut) { deal(12) } }
Divider()
HStack {
CreateNewGameButton(viewModel: viewModel)
DealNewCardbutton(viewModel: viewModel, deal: deal)
}.padding(.horizontal).frame(maxHeight: 50)
}
}
struct DealNewCardbutton: View {
var viewModel: SetViewModel
let deal: (Int) -> Void
init(viewModel: SetViewModel, deal: #escaping (Int) -> Void) {
self.viewModel = viewModel
self.deal = deal
}
var body: some View {
GeometryReader { geo in
Button(action: {
deal(3)
}){
ZStack {
RoundedRectangle(cornerRadius: 10.0).foregroundColor(.blue)
Text("Deal Three Cards").foregroundColor(.white)
}
}.onAppear {
print(geo.frame(in: .global).midX, geo.frame(in: .global).midY)
}
}
}
}
Heres a video of how it currently works.
https://i.imgur.com/NJfOjBP.mp4
I want the cards to all 'fly out' from the deal button.
Ok. This is pretty tricky. You can get the position of the button via GeometryGetter, but I'd recommend using anchorPreference. The only downside is that you'll have to specify your grid in an overlay or background of the view containing the button.
import SwiftUI
struct BoundsPreferenceKey: PreferenceKey {
static var defaultValue: [Int: Anchor<CGRect>] = [:]
static func reduce(value: inout [Int: Anchor<CGRect>], nextValue: () -> [Int: Anchor<CGRect>]) {
value.merge(nextValue()) { v1, _ in v1 }
}
}
extension View {
func reportBounds(id: Int) -> some View {
self.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds, transform: { [id: $0] })
}
}
private extension Int {
static let dealButtonId = 0
}
var body: some View {
VStack {
Divider()
HStack {
CreateNewGameButton(viewModel: viewModel)
DealNewCardbutton(viewModel: viewModel, deal: deal)
.reportBounds(id: .dealButtonId)
} .padding(.horizontal).frame(maxHeight: 50)
} .overlayPreferenceValue(BoundsPreferenceKey.self) { preferences in
GeometryReader { geometry in
self.getOverlay(preferences: preferences, geometry: geometry)
}
}
}
func getOverlay(preferences:(preferences : [Int: Anchor<CGRect>], geometry: GeometryProxy) -> some View {
var center: CGPoint = .zero
if let anchor = preferences[.dealButtonId] {
let rect = geometry[anchor]
center = CGPoint(x: rect.midX, y: rect.midY)
}
return VStack {
HStack {
Label("Score: \(viewModel.score)", systemImage: "face.dashed").font(.title).padding(.leading)
Spacer()
}
Grid(viewModel.dealtCards) { card in
CardView(card: card)
// USE CENTER here in your transition and/or offset
.transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .leading)))
.padding()
.onTapGesture { withAnimation { viewModel.choose(card) } }
} .onAppear { withAnimation(.easeInOut) { deal(12) } }
Spacer()
}
}
Note that I moved the grid and other views to the overlay, you may need to set explicit frame heights (to the spacer) to make sure the bottom of the grid and the top of the button align. I left the actual implementation of the transition for you.
Related
I have a pretty simple view modifier for presenting toasts
struct ToastItemModifier<Item: Equatable, M: View>: ViewModifier {
typealias Style = ToastStyle
// MARK: - Environment
#Environment(\.colorScheme)
var colorScheme
// MARK: - Properties
private let style: Style
private let item: Item?
private let message: (Item) -> M
#Binding
private var isPresented: Bool
init(item: Item?, isPresented: Binding<Bool>, #ViewBuilder message: #escaping (Item) -> M, style: Style) {
self.style = style
self.item = item
self.message = message
_isPresented = isPresented
}
/// Indicates if content is presented.
private var contentIsPresented: Bool {
item != nil && isPresented
}
private var animation: Animation {
.easeInOut(duration: 1)
}
// MARK: - ViewModifier
func body(content: Content) -> some View {
ZStack {
content
ZStack {
if let item = item, contentIsPresented {
overlay(item: item)
.transition(.asymmetric(insertion: .scale(scale: 0.5), removal: .opacity).animation(animation))
}
}
}
.onReceive(Timer.publish(every: 3, on: .main, in: .default).autoconnect().first()) { _ in
isPresented = false
}
}
private func overlay(item: Item) -> some View {
HStack {
Image(uiImage: style.image)
.padding(.leading, 20)
message(item)
.font(Font.custom("DMSans-Bold", size: 18))
.foregroundColor(.white)
.padding(.trailing)
}
.frame(height: 66)
.background(style.background(for: colorScheme))
.cornerRadius(20)
.edgesIgnoringSafeArea(.all)
.shadow(color: Color.black.opacity(0.15), radius: 12, x: 0, y: 8)
}
}
Using an asymmetric transition, I can achieve a scale upon insertion and an opacity fade upon removal. However, changing those transitions to ones like .slide or .move, completely breaks the transition.
Are certain transitions broken in SwiftUI? I've had great success with opacity and scale but the other ones don't seem to work.
See my comment to question (above) and I'd move animation to container, like
ZStack {
if let item = item, contentIsPresented {
overlay(item: item)
.transition(.asymmetric(insertion: .scale(scale: 0.5), removal: .opacity))
}
}
.animation(animation, value: isPresented) // << here !!
I am facing problem, where i cannot bind Object properly in swiftUI. what I want to do is animate when user tap deletion.
ScrollView(.horizontal, showsIndicators: false) {
HStack{
VStack{
ZStack(alignment: .center) {
ForEach(viewModel.cards) { card in
let deleteAction: (Int) -> Void = { index in
withAnimation {
viewModel.deleteCard(at: index) // this is where i want to animate changes
}
}
StatusCardView(cardViewModel: card, editMode: $editMode, shrinked: $shrinked, scale: $scaleFactor, onDelete: deleteAction)
.animation(.spring(), value: viewModel.cards)
}
}
.frame(width: 1, height: 1)
Spacer()
}
Spacer()
}
}
viewModel:
class BubblesViewModel: ObservableObject {
#Published private(set) var cards = [CardViewModel]() // it is filled in constructor (not important right now)
func deleteCard(at index: Int) {
cards.removeAll(where: {index == $0.index})
freeCards = cards
cards.removeAll()
layoutCards()
}
}
StatusCardView:
struct StatusCardView: View {
...
var onDeleteClosure: ((Int) -> Void)
init(cardViewModel: CardViewModel, editMode: Binding<Bool>, shrinked: Binding<Bool>, scale: Binding<CGFloat>, onDelete: #escaping ((Int) -> Void)) {
...
self.onDeleteClosure = onDelete
}
var body: some View {
ZStack {
...
ZStack {
if editMode {
Text("-")
.font(type.titleFont)
.frame(width: 15, height: 15, alignment: .center)
.foregroundColor(.white)
.background(Color.Card.foregroundOutline)
.cornerRadius(50)
.opacity(editMode ? 1 : 0)
.onTapGesture {
onDeleteClosure(model.index!) // closure is called here to move logic to ViewModel
}
}
}
Spacer()
}
Spacer()
}
}
}
}
}
when I click delete nothing happens. animation is performed only on scroll. Any ideas what seems to be a problem ? edit: I updated post and added code for StatusCardView
I have two views in a HStack - the left one is a simple Text view.
For the right view, i'm experimenting with using GeometryReader. I cannot get the baselines of the text aligned when I use geometry reader, but I can when I don't use it.
Here is some code:
struct CompositeView : View {
var body : some View {
VStack {
Button(action: {
}) {
Text("Another text")
.padding(5)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.blue, lineWidth: 2.0))
}
}
}
}
struct GeometryReaderTest : View {
var tags: [String]
init(tags : [String]) {
self.tags = tags
}
#State private var viewHeight = CGFloat.zero
var body: some View {
VStack {
GeometryReader { geometry in
ZStack {
ForEach(self.tags, id: \.self) { tag in
Button(action: {}) {
Text(tag)
.padding(5)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.blue, lineWidth: 2.0))
}
}
}
.background(getHeight(self.$viewHeight))
}
}
.frame(height: viewHeight)
}
/// Get the height of the Geometry view
///
private func getHeight(_ height: Binding<CGFloat>) -> some View {
return GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
height.wrappedValue = rect.size.height
}
return .clear
}
}
}
struct MyTestView : View {
var body : some View {
VStack {
HStack (alignment: .firstTextBaseline) {
ZStack {
Text("Hello")
}
GeometryReaderTest(tags: ["One"])
}
HStack (alignment: .firstTextBaseline) {
ZStack {
Text("Hello")
}
CompositeView()
}
}
}
}
Here is the outcome:
As you can see, the baseline alignment doesn't work when the right hand view is a GeometryReader, but it does work when the right hand view is a simple button.
Does anyone know how I can get the baselines to line up?
I'm using GeometryReader because my right-view will eventually be more complex - I'm reducing the issue I'm facing to as small a repro as possible.
Thank you!
I'm new to SwiftUI and working through some sample projects to get the hang of it and I'm getting stuck on limiting the scope of the animation I set for the .transition for an AnimationModifier so it only impacts the animation of the transition and nothing else in the view.
While the separate transitions are respected for onAppear() and another for onDisappear(). The animation in the AnimatableModifier is overriding the removal over the item from the grid even when explicitly declared
I've tried explcicitly setting the Animation to the .offset transition in both the AnimatableModifier and for the CardView in the GameView, and when I do, no animation is triggered at all:
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-50)).animation(Animation.easeInOut(duration: 1.25).delay(delay)))
So, there's gotta be a way to limit the scope or explicitly declare the animation for transition or two separate Animations in the Animation Modifier, but I'm not finding any resources on how to move forward.
GameView.swift
struct GameView: View {
#ObservedObject var viewModel: SetGameViewModel
#State var delay: Double = 0.1
var body: some View {
GeometryReader { geometry in
VStack {
Grid(newItems: self.viewModel.newCards,
items: self.viewModel.cards.itemsAtWithIds(ids: self.viewModel.idOfCardsToDisplay)) { card in
CardView(card: card, bodyGeoProxy: geometry, delay: self.delay).onTapGesture {
self.viewModel.choose(card: card)
}
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-50)))
.animation(Animation.easeInOut(duration: 1.25).delay(delay))
.onAppear() {
let maxDelay: Double = Double(self.viewModel.cards.itemsAtWithIds(ids: self.viewModel.idOfCardsToDisplay).count)*0.2 + 0.2
if self.delay < 2.5 {
self.delay = self.delay + 0.2
} else if self.delay >= maxDelay {
self.delay = 0.1
}
}
}
HStack{
Button(action: {
self.viewModel.dealThreeCards()
}) {
Text("Hit Me")
}
Spacer()
Text("Score: \(self.viewModel.score)")
Spacer()
Button(action: {
self.viewModel.dealThreeCards()
}) {
Text("New Game")
}
}
}
}
}
}
GameView.swift
struct CardView: View{
var card: SetGame<SoloSetCardContent>.Card
var bodyGeoProxy: GeometryProxy
var delay: Double
var body: some View {
GeometryReader { geometry in
self.body(for: geometry)
}
}
init(card: SetGame<SoloSetCardContent>.Card, bodyGeoProxy: GeometryProxy, delay: Double) {
self.card = card
self.bodyGeoProxy = bodyGeoProxy
self.delay = delay
}
#ViewBuilder
func body(for geometryProxy: GeometryProxy) -> some View {
ZStack {
if card.isSelected {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray)
.frame(width: geometryProxy.size.width-4, height: geometryProxy.size.height-4, alignment: .center)
.border(Color.blue, width: 2)
.animation(nil)
} else {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray)
.frame(width: geometryProxy.size.width-4, height: geometryProxy.size.height-4, alignment: .center)
.border(Color.red, width: 2)
.animation(nil)
}
VStack {
ForEach(0..<self.card.content.deckShapes.count) { index in
VStack {
Spacer(minLength: 5)
ShapeView(setShape: self.card.content.deckShapes[index])
.frame(width: (geometryProxy.size.width-geometryProxy.size.width/5), height: geometryProxy.size.height/5, alignment: .center)
Spacer(minLength: 5)
}
}
}
}
.deal(delay: self.delay, offset: bodyGeoProxy.size)
}
}
Dealer.Swift - AnimatableModifier
struct Dealer: AnimatableModifier {
#State var show: Bool = false
var delay: Double
var offset: CGSize
var randomXLocation: CGFloat {
CGFloat.random(in: -offset.width ..< offset.width)
}
func body(content: Content) -> some View {
ZStack {
if show {
content
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-450)))
.animation(Animation.easeInOut(duration: 1.25).delay(delay))
}
}
.onAppear {
withAnimation {
self.show = true
}
}
.onDisappear {
withAnimation {
self.show = false
}
}
}
}
extension View {
func deal(delay: Double, offset: CGSize) -> some View {
self.modifier(Dealer(delay: delay, offset: offset))
}
}
I was able to resolve this by removing the Animation from the body content (and elsewhere) and adding to withAnimation portion of the .onAppear method in body function of the AnimationModifier
func body(content: Content) -> some View {
ZStack {
if show {
content
.transition(.asymmetric(insertion: .offset(CGSize.init(width: randomXLocation, height: -offset.height-50)),
removal: .offset(CGSize.init(width: randomXLocation, height: offset.height+50))))
}
}
.onDisappear {
withAnimation (Animation.easeInOut(duration: 1.25).delay(0)) {
self.show = false
}
}
.onAppear {
withAnimation (Animation.easeInOut(duration: 1.25).delay(self.delay)) {
self.show = true
}
}
}
I'm trying to hide the indicators of a ScrollView but when I try doing so, the ScrollView just doesn't scroll anymore. I'm using macOS if that matters.
ScrollView(showsIndicators: false) {
// Everything is in here
}
On request of #SoOverIt
Demo:
Nothing special, just launched some other test example. Xcode 11.2 / macOS 10.15
var body : some View {
VStack {
ScrollView([.vertical], showsIndicators: false) {
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
}
.frame(height: 100)
.border(Color.blue)
}
.border(Color.red)
}
I fixed the issue.
extension View {
func hideIndicators() -> some View {
return PanelScrollView{ self }
}
}
struct PanelScrollView<Content> : View where Content : View {
let content: () -> Content
var body: some View {
PanelScrollViewControllerRepresentable(content: self.content())
}
}
struct PanelScrollViewControllerRepresentable<Content>: NSViewControllerRepresentable where Content: View{
func makeNSViewController(context: Context) -> PanelScrollViewHostingController<Content> {
return PanelScrollViewHostingController(rootView: self.content)
}
func updateNSViewController(_ nsViewController: PanelScrollViewHostingController<Content>, context: Context) {
}
typealias NSViewControllerType = PanelScrollViewHostingController<Content>
let content: Content
}
class PanelScrollViewHostingController<Content>: NSHostingController<Content> where Content : View {
var scrollView: NSScrollView?
override func viewDidAppear() {
self.scrollView = findNSScrollView(view: self.view)
self.scrollView?.scrollerStyle = .overlay
self.scrollView?.hasVerticalScroller = false
self.scrollView?.hasHorizontalScroller = false
super.viewDidAppear()
}
func findNSScrollView(view: NSView?) -> NSScrollView? {
if view?.isKind(of: NSScrollView.self) ?? false {
return (view as? NSScrollView)
}
for v in view?.subviews ?? [] {
if let vc = findNSScrollView(view: v) {
return vc
}
}
return nil
}
}
Preview:
struct MyScrollView_Previews: PreviewProvider {
static var previews: some View {
ScrollView{
VStack{
Text("hello")
Text("hello")
Text("hello")
Text("hello")
Text("hello")
}
}.hideIndicators()
}
}
So... I think that's the only way for now.
You basically just put a View over your ScrollView indicator with the same backgroundColor as your background View
Note: This obviously only works if your background is static with no content at the trailing edge.
Idea
struct ContentView: View {
#Environment(\.colorScheme) var colorScheme: ColorScheme
let yourBackgroundColorLight: Color = .white
let yourBackgroundColorDark: Color = .black
var yourBackgroundColor: Color { colorScheme == .light ? yourBackgroundColorLight : yourBackgroundColorDark }
var body: some View {
ScrollView {
VStack {
ForEach(0..<1000) { i in
Text(String(i)).frame(width: 280).foregroundColor(.green)
}
}
}
.background(yourBackgroundColor) //<-- Same
.overlay(
HStack {
Spacer()
Rectangle()
.frame(width: 10)
.foregroundColor(yourBackgroundColor) //<-- Same
}
)
}
}
Compact version
You could improve this like that, I suppose you have your color dynamically set up inside assets.
Usage:
ScrollView {
...
}
.hideIndicators(with: <#Your Color#>)
Implementation:
extension View {
func hideIndicators(with color: Color) -> some View {
return modifier(HideIndicators(color: color))
}
}
struct HideIndicators: ViewModifier {
let color: Color
func body(content: Content) -> some View {
content
.overlay(
HStack {
Spacer()
Rectangle()
.frame(width: 10)
.foregroundColor(color)
}
)
}
}