So, I've been using swiftUI almost from the time of it's release and playing around with it. However, I'm trying to add a new feature into my app which doesn't seem to work as desired.
I want to have an Image carousel(automatic, meaning the image change after a regular interval) with transition effect. After doing a bit of research ,I could possibly find a method to do so. But, it's not coming together.
Here's my code: SwipeImages.swift
import SwiftUI
struct SwipeImages:View{
#State private var difference: CGFloat = 0
#State private var index = 0
let spacing:CGFloat = 10
var timer: Timer{
Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { (timer) in
if self.index < images.count - 1{
self.index += 1
}
else{
self.index = 0
}
}
}
var body:some View{
ContentView(imageData: images[self.index])
.onAppear {
let _ = self.timer
}
}
}
public struct ImageData:Identifiable{
public let id:Int
let image:String
let title:String
let country:String
}
let images = [ImageData(id:0,image:"1a.jpg",title: "String1", country: "country1"),
ImageData(id:1,image:"2a.jpg",title: "String2", country: "country2"),
ImageData(id:2,image:"3a.jpg",title: "String3", country: "country3")]
ContentView.swift
import SwiftUI
struct ContentView:View{
let imageData:ImageData
var transitionStyle:Int = 1
var transition:AnyTransition{
switch transitionStyle{
case 0:
return .opacity
case 1:
return .circular
case 2:
return .stripes(stripes:50,horizontal:true)
default:
return .opacity
}
}
var body:some View{
ZStack{
Image(uiImage: UIImage(named: "\(imageData.image)")!)
.resizable()
.transition(self.transition)
.overlay(
Rectangle()
.fill(LinearGradient(gradient: Gradient(colors: [.clear,.black]), startPoint: .center, endPoint: .bottom))
.clipped()
)
.cornerRadius(2.0)
VStack(alignment: .leading) {
Spacer()
Text("\(imageData.title)")
.font(.title)
.fontWeight(.semibold)
.foregroundColor(.white)
Text(imageData.country)
.foregroundColor(.white)
}
.padding()
}
.shadow(radius:12.0)
.cornerRadius(12.0)
}
}
extension Image{
func imageStyle(height:CGFloat) -> some View{
let shape = RoundedRectangle(cornerRadius: 15.0)
return self.resizable()
.frame(height:height)
.overlay(
Rectangle()
.fill(LinearGradient(gradient: Gradient(colors: [.clear,.black]), startPoint: .center, endPoint: .bottom))
.clipped()
)
.cornerRadius(2.0)
.clipShape(shape)
}
}
extension AnyTransition{
static var circular: AnyTransition{
get{
AnyTransition.modifier(active: ShapeClipModifier(shape: CircleClipShape(pct:1)), identity: ShapeClipModifier(shape: CircleClipShape(pct:0)))
}
}
static func stripes(stripes s:Int,horizontal isHorizontal:Bool) -> AnyTransition{
return AnyTransition.asymmetric(insertion: AnyTransition.modifier(active: ShapeClipModifier(shape:StripeShape(insertion:true,pct:1,stripes:s,horizontal:isHorizontal)), identity:
ShapeClipModifier(shape:StripeShape(insertion:true,pct:0,stripes:s,horizontal:isHorizontal))
), removal:AnyTransition.modifier(active: ShapeClipModifier(shape:StripeShape(insertion:false,pct:1,stripes:s,horizontal:isHorizontal))
, identity:
ShapeClipModifier(shape:StripeShape(insertion:false,pct:0,stripes:s,horizontal:isHorizontal)))
)
}
}
struct ShapeClipModifier<S: Shape>: ViewModifier{
let shape: S
func body(content:Content) -> some View {
content.clipShape(shape)
}
}
struct StripeShape: Shape{
let insertion: Bool
var pct: CGFloat
let stripes: Int
let horizontal: Bool
var animatableData: CGFloat{
get{pct}
set{pct = newValue}
}
func path(in rect:CGRect) -> Path{
var path = Path()
let stripeHeight = rect.height/CGFloat(stripes)
for i in 0..<stripes{
let iteratorValue = CGFloat(i)
if insertion{
path.addRect(CGRect(x: 0, y: iteratorValue * stripeHeight, width: rect.width, height: stripeHeight * (1 - pct)))
}
else{
path.addRect(CGRect(x: 0, y: iteratorValue * stripeHeight + (stripeHeight * pct), width: rect.width, height: stripeHeight * (1 - pct)))
}
}
return path
}
}
struct CircleClipShape: Shape{
var pct:CGFloat
var animatableData: CGFloat{
get{pct}
set{pct = newValue}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var bigRect = rect
bigRect.size.width = bigRect.size.width * 2 * (1-pct)
bigRect.size.height = bigRect.size.height * 2 * (1-pct)
bigRect = bigRect.offsetBy(dx: -rect.width/2.0, dy: -rect.height/2.0)
path = Circle().path(in: bigRect)
return path
}
}
MainView.swift
import SwiftUI
struct MainView:View{
var body:some View{
VStack{
SwipeImages()
.padding()
}
}
}
When the app is launched the images change after scheduled interval specified ,i.e: 2s , but the required transition such as stripes or circular ,nothing seem to come in action. (Also, I tried applying regular inbuilt transitions such as slide and opacity, even they aren't working).
Could you please help me identifying what's wrong, or any other alternatives to achieve the same?
Thanks.
Found somewhat similar to what you are trying to achieve here The guy implemented custom SwiftUI view, with option to animate automatically or by swiping. Very basic implementation but still you can get some ideas.
Related
I have one view which I am using in another view, and i want to access property from one view to another...but i am not sure how to do it.. (My main problem with below code - I really appreciate if I got solution for this - is I am having dropdown even when it is collapsed its expanding when i clicked outside of it..which is wrong)
import SwiftUI
public struct TopSheet<Content>: View where Content : View {
private var content: () -> Content
let minHeight: CGFloat = 50.0
let startHeight: CGFloat = 50.0
let maxOpacity: CGFloat = 0.8
let maxArrowOffset: CGFloat = 8.0
let minimumContentHeight = 61.0
#State private var currentHeight: CGFloat = 0
#State private var contentHeight: CGFloat = 0
#State private var backgroundColor: Color = Color.clear
#State private var expand = false
#State private var arrowOffset: Double = 0
public init(#ViewBuilder content: #escaping () -> Content) { self.content = content }
public func expandRatio() -> Double { return max((currentHeight - minHeight) / contentHeight, 0) }
private func isTopSheetExpandable() -> Bool {
return contentHeight > minimumContentHeight
}
public var body: some View {
let tap = TapGesture()
.onEnded { _ in
expand.toggle()
if expand {
withAnimation(Animation.easeOut) {
currentHeight = max(contentHeight, minHeight)
self.backgroundColor = Color.grey
}
}
else {
withAnimation(Animation.easeOut) {
currentHeight = minHeight
self.backgroundColor = Color.clear
}
}
self.arrowOffset = expandRatio() * maxArrowOffset
}
let drag = DragGesture()
.onChanged { value in
currentHeight += value.translation.height
currentHeight = max(currentHeight, minHeight)
let opacity = min(expandRatio() * maxOpacity, maxOpacity)
self.backgroundColor = Color.gray.opacity(opacity)
self.arrowOffset = expandRatio() * maxArrowOffset
}
.onEnded { value in
expand.toggle()
if expand {
withAnimation(Animation.easeOut) {
currentHeight = max(contentHeight, minHeight)
self.backgroundColor = Color.gray.opacity(maxOpacity)
}
}
else {
withAnimation(Animation.easeOut) {
currentHeight = minHeight
self.backgroundColor = Color.clear
}
}
self.arrowOffset = expandRatio() * maxArrowOffset
}
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
VStack(spacing: 0) {
GeometryReader { geo in
content()
.viewHeight()
.fixedSize(horizontal: false, vertical: true)
}.onPreferenceChange(ViewHeightPreferenceKey.self) { height in
contentHeight = height
currentHeight = startHeight
}.clipped()
Spacer(minLength: 0)
}
.frame(height: currentHeight)
HStack(alignment: .center) {
Spacer()
if isTopSheetExpandable() {
Arrow(offset: arrowOffset)
.stroke(Color.gray, style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
.frame(width: 30, height: 4)
.padding(.bottom, 10)
.padding(.top, 10)
}
Spacer()
}
// .contentShape(Rectangle())
.gesture(drag)
.gesture(tap)
.animation(.easeInOut, value: 2)
}
.background(Color.white)
Spacer()
}
.background(self.backgroundColor.edgesIgnoringSafeArea([.vertical, .horizontal, .leading, .trailing, .top, .bottom]))
.simultaneousGesture(isTopSheetExpandable() ? tap : nil)
}
}
fileprivate struct Arrow: Shape {
var offset: Double
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: .zero)
path.addLine(to: CGPoint(x: rect.width/2, y: -offset))
path.move(to: CGPoint(x: rect.width/2, y: -offset))
path.addLine(to: CGPoint(x: rect.width, y: 0))
return path
}
}
Another view
import SwiftUI
struct NextView: View {
#State private var backgroundColor: Color = Color.gray
#State private var users: [String] = ["abc", "xyz", "pqr", "mno", "pqr", "ert", ""]
var accessibilityID: String
var body: some View {
ZStack {
Rectangle()
.fill()
.foregroundColor(backgroundColor)
TopSheet {
VStack(spacing: 0) {
ForEach($users, id: \.self) { user in
HStack {
Text(user.wrappedValue)
.padding(.vertical, 10)
Spacer()
}
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
self.users = [user.wrappedValue] + self.users.filter { $0 != user.wrappedValue }
switch self.users.first.unsafelyUnwrapped {
case "Joe Black": self.backgroundColor = .black
case "Eva Green": self.backgroundColor = .green
case "Jared Leto": self.backgroundColor = .red
default: self.backgroundColor = .gray
}
})
}
}
.padding(.horizontal, 10)
}
}
}
}
I might try some trick if I can able to access "expand" property from "TopSheet" and access it in "NextView"
You should store expand as #State in your parent View and pass it via Binding. Simplified example:
public struct TopSheet<Content>: View where Content : View {
#Binding var expand : Bool
var content: () -> Content
//declare other properties private so they don't get used in the generated init
public var body: some View {
Text("Here")
}
}
struct NextView: View {
#State private var expand = false
var body: some View {
TopSheet(expand: $expand) {
Text("Content")
}
}
}
If you really want/need your explicit init, you can do this:
public struct TopSheet<Content>: View where Content : View {
#Binding private var expand : Bool
private var content: () -> Content
public init(expand: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._expand = expand
self.content = content
}
public var body: some View {
Text("Here")
}
}
I have created a slider view which consists of 2 images, a chevron pointer and a slider line. My ultimate goal is to offset the button in the opposite direction to the chevron image so that when the user clicks the empty space the button will slide to its location. Below is a gif of me attempting to achieve this. My approach is quite manual and I suspect there is a sophisticated way of achieving my goal. I have it working when I click the button once, I suspect that on the other side I have my numbers wrong so it doesn't work when the image is on the left side of the screen.
import SwiftUI
extension Animation {
static func smooth() -> Animation {
Animation.spring()
.speed(0.7)
}
}
struct SliderView: View {
//this works as a toggle you can implement other booleans with this
#State var partyType: Bool = false
#State private var isOn = false
#State var width: CGFloat = 0
#State var height: CGFloat = 0
let screen = UIScreen.main.bounds
var body: some View {
ZStack {
Toggle("",isOn: $isOn).toggleStyle(SliderToggle())
}
}
func imageWidthX(name: String) -> CGFloat {
let image = UIImage(named: name)!
let width = image.size.width
return width * 2.5
}
func hostParty() -> Bool {
if partyType {
return true
} else {
return false
}
}
}
struct SlideEffect: GeometryEffect {
var offset: CGSize
var animatableData: CGSize.AnimatableData {
get { CGSize.AnimatableData(offset.width, offset.height) }
set { offset = CGSize(width: newValue.first, height: newValue.second) }
}
public func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(
CGAffineTransform(
translationX: offset.width,
y: offset.height
)
)
}
}
struct SliderView_Previews: PreviewProvider {
static var previews: some View {
SliderView()
}
}
struct SliderToggle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
GeometryReader{ geo in
VStack (alignment: .leading,spacing: 0){
Button(action: {
withAnimation() {
configuration.isOn.toggle()
}
}) {
Image("chevronPointer")
.clipped()
.animation(.smooth())
.padding(.horizontal, 30)
.offset(x: geo.size.width - 100, y: 0)
}
.modifier(
SlideEffect(
offset: CGSize(
width: configuration.isOn ? -geo.size.width + imageWidthX(name: "chevronPointer") : 0,
height: 0.0
)
)
)
Image("sliderLine")
.resizable()
.scaledToFit()
.frame(width: geo.size.width, alignment: .center)
}
}
.frame(height: 40)
}
}
func imageWidthX(name: String) -> CGFloat {
let image = UIImage(named: name)!
let width = image.size.width
return width * 2.5
}
I worded the question pretty poorly but ultimately the goal I wanted to achieve was to have 2 buttons on a stack that controlled the view under it. When the opposite side is clicked the slider icon will travel to that side and vice versa. The offset made things a lot more complicated than necessary so I removed this code and basically created invisible buttons. IF there is a more elegant way top achieve this I would be truly greatful
HStack {
ZStack {
Button (" ") {
withAnimation(){
configuration.isOn.toggle()
}}
.padding(.trailing).frame(width: 70, height: 25)
Image("chevronPointer")
.clipped()
.animation(.smooth())
.padding(.horizontal, 30)
//.offset(x: geo.size.width - 100, y: 0)
.modifier(SlideEffect(offset: CGSize(width: configuration.isOn ? geo.size.width - imageWidthX(name: "chevronPointer"): 0, height: 0.0)))
}
Spacer()
Button(" "){
withAnimation(){
configuration.isOn.toggle()
}
}.padding(.trailing).frame(width: 70, height: 25)
}
I created a circularprogress view to be able to show a progress bar according to the steps data. But for some reason I can not reach to the step.count inside my stepView file.
This is my StepView
struct StepView: View {
private var healthStore: HealthStore?
#State private var presentClipboardView = true
#State private var steps: [Step] = [Step]()
init() {
healthStore = HealthStore()
}
private func updateUIFromStatistics(_ statisticsCollection: HKStatisticsCollection) {
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
statisticsCollection.enumerateStatistics(from: startOfDay, to: now) { (statistics, stop) in
let count = statistics.sumQuantity()?.doubleValue(for: .count())
let step = Step(count: Int(count ?? 0), date: statistics.startDate, wc: Double(count ?? 0 / 1000 ))
steps.append(step)
}
}
var body: some View {
VStack {
ForEach(steps, id: \.id) { step in
VStack {
HStack{
Text("WC")
Text("\(step.wc)")
}
HStack {
Text("\(step.count ?? 0)")
Text("Total Steps")
}
Text(step.date, style: .date)
.opacity(0.5)
CircularProgress(steps: step.count) //ERROR
Spacer()
}
}
.navigationBarBackButtonHidden(true)
}
.onAppear() {
if let healthStore = healthStore {
healthStore.requestAuthorization { (success) in
if success {
healthStore.calculateSteps { (statisticsCollection) in
if let statisticsCollection = statisticsCollection {
updateUIFromStatistics(statisticsCollection)
}
}
}
}
}
}
.onDisappear() {
self.presentClipboardView.toggle()
}
}
}
and this is my circularprogress view
struct CircularProgress: View {
var steps: Binding<Int>
var body: some View {
ZStack {
Color.progressBarColor
.edgesIgnoringSafeArea(.all)
VStack {
ZStack {
Label()
Outline(steps: steps)
}
}
}
}
}
struct Label: View {
var percentage: CGFloat = 20
var body : some View {
ZStack {
Text(String(format: "%.0f", percentage))
.font(Font.custom("SFCompactDisplay-Bold", size: 56))
}
}
}
struct Outline: View {
var steps: Binding<Int>
var percentage: CGFloat = 20
var colors : [Color] = [Color.trackProgressBarColor]
var body: some View {
ZStack {
Circle()
.fill(Color.clear)
.frame(width: 250, height: 250)
.overlay(
Circle()
.trim(from: 0, to: percentage * 0.01)
.stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .round))
.fill(AngularGradient(gradient: .init(colors: colors), center: .center, startAngle: .zero, endAngle: .init(degrees: 360)))
).animation(.spring(response: 2.0, dampingFraction: 1.0, blendDuration: 1.0))
}
}
}
I am getting this error at stepview WHILE CALLING CIRCULARPROGRESS inside the stepview. I guess I am trying to get the data in the wrong way.
I don't see necessity of binding here, so just replace corresponding places with simple Int:
struct CircularProgress: View {
var steps: Int
and
struct Outline: View {
var steps: Int
I have 25 buttons laid out in a grid for a game like this.
I'm generating these buttons like this:
VStack {
ForEach(1..<6) {_ in
HStack {
ForEach(1..<6) {_ in
Button(action: {
// Button clicked
doButton()
}) {
Rectangle()
.frame(width: 50, height: 50)
.border(Color.black, lineWidth: 1)
}
}
}
}
}
How can I make it so if the user drags their finger across multiple buttons, each one of the buttons are clicked, and doButton() is executed?
I tried using a DragGesture() but couldn't get it to work...
Swift 5.1, iOS 13.
I don't have a 100% solution for you, but I do have something to get you off the ground hopefully. These are not buttons, but simply squares. As I click/drag across them they^ll signal I passed by.
It isn't perfect, but should give you some thing more to work with.
I read a tutorial on medium on doing something similar with drag and drop. This one.
Can help but think you'll need to do something with drag here and the Geometry reader ultimately, detecting where they are and responding to that.
import SwiftUI
struct ContentView: View {
var body: some View {
return VStack {
ForEach(1..<6) {colY in
HStack {
ForEach(1..<6) {rowX in
boxView(row: rowX, col: colY)
}
}
}
}
}
}
struct boxView: View {
#State var row: Int
#State var col: Int
var body: some View {
let dragGesture = DragGesture(minimumDistance: 0, coordinateSpace: CoordinateSpace.global)
.onChanged { (value) in
print("trigger ",self.row,self.col)
}
return Rectangle()
.stroke(Color.black)
.frame(width: 50, height: 50)
.gesture(dragGesture)
}
}
import SwiftUI
class Container : ObservableObject {
#Published var location = CGPoint()
public func updateLocation(_ location: CGPoint) {
self.location = location
}
}
struct SliderGestureButtonsView: View {
#ObservedObject var container = Container()
var body: some View {
VStack(alignment: .center, spacing: 0) {
ForEach(1..<6) { colY in
HStack {
ForEach(1..<6) { rowX in
GeometryReader { gp in
let x = gp.frame(in:.named("hive")).midX
let y = gp.frame(in:.named("hive")).midY
let w = gp.frame(in:.named("hive")).size.width
let h = gp.frame(in:.named("hive")).size.height
let rect = CGRect(x: x, y: y, width: w, height: h)
boxView(row: rowX, col: colY, rect: rect, container: container)
}
}
}.frame(height: 60)
}
}.coordinateSpace(name: "hive").frame(width: 300)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: CoordinateSpace.named("hive"))
.onChanged { (value) in
container.updateLocation(value.location)
}
)
}
}
struct boxView: View {
var row: Int
var col: Int
var rect: CGRect
#ObservedObject var container: Container
var body: some View {
let x = container.location.x
let y = container.location.y
let hw = rect.size.width / 2
let ox = rect.origin.x
let withinX = x > ox - hw && x < ox + hw
let hh = rect.size.height / 2
let oy = rect.origin.y
let withinY = y > oy - hh && y < oy + hh
var pressed = false
if(withinX && withinY) {
pressed = true
print("trigger ", self.row, self.col)
}
return Rectangle()
.stroke(Color.white)
.frame(width: 60, height: 60)
.background(pressed ? Color.green : Color.black)
}
}
I am trying to create a card flip effect between two SwiftUI Views. When clicking on the original view, it 3D rotates on the Y axis like when flipping a card, and the second view should start being visible after 90 degrees have been made.
Using .rotation3DEffect() I can easily rotate a view, the issue is that with the animation() I don't know how to trigger the View change once the angle has reached 90 degrees...
#State var flipped = false
var body: some View {
return VStack{
Group() {
if !self.flipped {
MyView(color: "Blue")
} else {
MyView(color: "Red")
}
}
.animation(.default)
.rotation3DEffect(self.flipped ? Angle(degrees: 90): Angle(degrees: 0), axis: (x: CGFloat(0), y: CGFloat(10), z: CGFloat(0)))
.onTapGesture {
self.flipped.toggle()
}
}
How to achieve such a rotation between two views ?
Simple Solution
The approach you're taking can be made to work by putting your two views in a ZStack and then showing/hiding them as the flipped state changes. The rotation of the second view needs to be offset. But this solution relies on a cross-fade between the two views. It might be OK for some uses cases. But there is a better solution - though it's a bit more fiddly (see below).
Here's a way to make your approach work:
struct SimpleFlipper : View {
#State var flipped = false
var body: some View {
let flipDegrees = flipped ? 180.0 : 0
return VStack{
Spacer()
ZStack() {
Text("Front").placedOnCard(Color.yellow).flipRotate(flipDegrees).opacity(flipped ? 0.0 : 1.0)
Text("Back").placedOnCard(Color.blue).flipRotate(-180 + flipDegrees).opacity(flipped ? 1.0 : 0.0)
}
.animation(.easeInOut(duration: 0.8))
.onTapGesture { self.flipped.toggle() }
Spacer()
}
}
}
extension View {
func flipRotate(_ degrees : Double) -> some View {
return rotation3DEffect(Angle(degrees: degrees), axis: (x: 1.0, y: 0.0, z: 0.0))
}
func placedOnCard(_ color: Color) -> some View {
return padding(5).frame(width: 250, height: 150, alignment: .center).background(color)
}
}
Better Solution SwiftUI has some useful animation tools - such as GeometryEffect - that can generate a really smooth version of this effect. There are some excellent blog posts on this topic at SwiftUI Lab. In particular, see: https://swiftui-lab.com/swiftui-animations-part2/
I've simplified and adapted one of examples in that post to provide the card flipping functionality.
struct FlippingView: View {
#State private var flipped = false
#State private var animate3d = false
var body: some View {
return VStack {
Spacer()
ZStack() {
FrontCard().opacity(flipped ? 0.0 : 1.0)
BackCard().opacity(flipped ? 1.0 : 0.0)
}
.modifier(FlipEffect(flipped: $flipped, angle: animate3d ? 180 : 0, axis: (x: 1, y: 0)))
.onTapGesture {
withAnimation(Animation.linear(duration: 0.8)) {
self.animate3d.toggle()
}
}
Spacer()
}
}
}
struct FlipEffect: GeometryEffect {
var animatableData: Double {
get { angle }
set { angle = newValue }
}
#Binding var flipped: Bool
var angle: Double
let axis: (x: CGFloat, y: CGFloat)
func effectValue(size: CGSize) -> ProjectionTransform {
DispatchQueue.main.async {
self.flipped = self.angle >= 90 && self.angle < 270
}
let tweakedAngle = flipped ? -180 + angle : angle
let a = CGFloat(Angle(degrees: tweakedAngle).radians)
var transform3d = CATransform3DIdentity;
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
return ProjectionTransform(transform3d).concatenating(affineTransform)
}
}
struct FrontCard : View {
var body: some View {
Text("One thing is for sure – a sheep is not a creature of the air.").padding(5).frame(width: 250, height: 150, alignment: .center).background(Color.yellow)
}
}
struct BackCard : View {
var body: some View {
Text("If you know you have an unpleasant nature and dislike people, this is no obstacle to work.").padding(5).frame(width: 250, height: 150).background(Color.green)
}
}
Update
The OP asks about managing the flip status outside of the view. This can be done by using a binding. Below is a fragment that implements and demos this. And OP also asks about flipping with and without animation. This is a matter of whether changing the flip state (here with the showBack var) is done within an animation block or not. (The fragment doesn't include FlipEffect struct which is just the same as the code above.)
struct ContentView : View {
#State var showBack = false
let sample1 = "If you know you have an unpleasant nature and dislike people, this is no obstacle to work."
let sample2 = "One thing is for sure – a sheep is not a creature of the air."
var body : some View {
let front = CardFace(text: sample1, background: Color.yellow)
let back = CardFace(text: sample2, background: Color.green)
let resetBackButton = Button(action: { self.showBack = true }) { Text("Back")}.disabled(showBack == true)
let resetFrontButton = Button(action: { self.showBack = false }) { Text("Front")}.disabled(showBack == false)
let animatedToggle = Button(action: {
withAnimation(Animation.linear(duration: 0.8)) {
self.showBack.toggle()
}
}) { Text("Toggle")}
return
VStack() {
HStack() {
resetFrontButton
Spacer()
animatedToggle
Spacer()
resetBackButton
}.padding()
Spacer()
FlipView(front: front, back: back, showBack: $showBack)
Spacer()
}
}
}
struct FlipView<SomeTypeOfViewA : View, SomeTypeOfViewB : View> : View {
var front : SomeTypeOfViewA
var back : SomeTypeOfViewB
#State private var flipped = false
#Binding var showBack : Bool
var body: some View {
return VStack {
Spacer()
ZStack() {
front.opacity(flipped ? 0.0 : 1.0)
back.opacity(flipped ? 1.0 : 0.0)
}
.modifier(FlipEffect(flipped: $flipped, angle: showBack ? 180 : 0, axis: (x: 1, y: 0)))
.onTapGesture {
withAnimation(Animation.linear(duration: 0.8)) {
self.showBack.toggle()
}
}
Spacer()
}
}
}
struct CardFace<SomeTypeOfView : View> : View {
var text : String
var background: SomeTypeOfView
var body: some View {
Text(text)
.multilineTextAlignment(.center)
.padding(5).frame(width: 250, height: 150).background(background)
}
}
A cleaned up and extendable solution
Note that you can easily change flip direction by changing axis parameter in .rotation3DEffect.
import SwiftUI
struct FlipView<FrontView: View, BackView: View>: View {
let frontView: FrontView
let backView: BackView
#Binding var showBack: Bool
var body: some View {
ZStack() {
frontView
.modifier(FlipOpacity(percentage: showBack ? 0 : 1))
.rotation3DEffect(Angle.degrees(showBack ? 180 : 360), axis: (0,1,0))
backView
.modifier(FlipOpacity(percentage: showBack ? 1 : 0))
.rotation3DEffect(Angle.degrees(showBack ? 0 : 180), axis: (0,1,0))
}
.onTapGesture {
withAnimation {
self.showBack.toggle()
}
}
}
}
private struct FlipOpacity: AnimatableModifier {
var percentage: CGFloat = 0
var animatableData: CGFloat {
get { percentage }
set { percentage = newValue }
}
func body(content: Content) -> some View {
content
.opacity(Double(percentage.rounded()))
}
}