SwiftUI - Change direction of view hide animation - swiftui

I've been perusing SO, and elsewhere, trying to find a way, if possible, to change the direction of how a view is hidden. The examples I've found hide a view by replacing it with an EmptyView, or changing the view's frame dimensions to zero. I am trying to hide a view by 'collapsing' it vertically, but everywhere I've looked the collapse/hide animation happens upwards.
In my case, a view will have a button underneath it that will collapse (hide) or expand (show) the view. The view and button are embedded in a scroll view. What I'd like to have happen is that when the view is wholly or partially scrolled up off the top of the screen, and the collapse button is tapped, the view should 'collapse downward' such that the button remains where it is. Everything I've tried causes the button to move upward off the screen.
I think that what has to happen is that the view origin's y value needs to change. But what would happen to all other views above this view? I've tried to accomplish what I've described by changing the transition but it's not having any affect.
I feel like I'm really missing something basic here so thanks for any help.
struct Collapsible<Content: View>: View {
private var content: () -> Content
#Binding var isCollapsed: Bool
#State var isOffscreen: Bool = false
init(isCollapsed: Binding<Bool>, content: #escaping () -> Content) {
self._isCollapsed = isCollapsed
self.content = content
}
var body: some View {
VStack {
content()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: isCollapsed ? 0 : nil)
.clipped()
.animation(.default, value: isCollapsed)
.transition(transition.combined(with: .opacity))
}
.background(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
let frame = proxy.frame(in: CoordinateSpace.global)
self.isOffscreen = frame.origin.y < 0
var _ = print("isOffscreen: \(self.isOffscreen)")
}
return Color.clear
}
)
}
var transition: AnyTransition {
if isOffscreen {
return AnyTransition.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
} else {
return AnyTransition.asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom))
}
}
}
struct ContentView: View {
#State var isCollapsed = false
var body: some View {
ScrollView {
VStack {
Color.clear.frame(height: 250)
Collapsible(isCollapsed: $isCollapsed) {
HStack {
Text("Content")
}
.frame(maxWidth: .infinity)
.padding([.top, .bottom], 75)
.background(.secondary)
.border(.blue, width: 2)
}
Button(action: {
self.isCollapsed.toggle()
}, label: {
Text(isCollapsed ? "Expand" : "Collapse")
})
.buttonStyle(PlainButtonStyle())
Color.clear.frame(height: 1000)
}
.padding()
}
}
}

Related

How to make a custom UIView Appear/Dissapear in SwiftUI

I have a CameraView in my app that I'd like to bring up whenever a button is to be presssed. It's a custom view that looks like this
// The CameraView
struct Camera: View {
#StateObject var model = CameraViewModel()
#State var currentZoomFactor: CGFloat = 1.0
#Binding var showCameraView: Bool
// MARK: [main body starts here]
var body: some View {
GeometryReader { reader in
ZStack {
// This black background lies behind everything.
Color.black.edgesIgnoringSafeArea(.all)
CameraViewfinder(session: model.session)
.onAppear {
model.configure()
}
.alert(isPresented: $model.showAlertError, content: {
Alert(title: Text(model.alertError.title), message: Text(model.alertError.message), dismissButton: .default(Text(model.alertError.primaryButtonTitle), action: {
model.alertError.primaryAction?()
}))
})
.scaledToFill()
.ignoresSafeArea()
.frame(width: reader.size.width,height: reader.size.height )
// Buttons and controls on top of the CameraViewfinder
VStack {
HStack {
Button {
//
} label: {
Image(systemName: "xmark")
.resizable()
.frame(width: 20, height: 20)
.tint(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
Spacer()
flashButton
}
HStack {
capturedPhotoThumbnail
Spacer()
captureButton
Spacer()
flipCameraButton
}
.padding([.horizontal, .bottom], 20)
.frame(maxHeight: .infinity, alignment: .bottom)
}
} // [ZStack Ends Here]
} // [Geometry Reader Ends here]
} // [Main Body Ends here]
// More view component code goes here but I've excluded it all for brevity (they don't add anything substantial to the question being asked.
} // [End of CameraView]
It contains a CameraViewfinder View which conforms to the UIViewRepresentable Protocol:
struct CameraViewfinder: UIViewRepresentable {
class VideoPreviewView: UIView {
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
var videoPreviewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
}
let session: AVCaptureSession
func makeUIView(context: Context) -> VideoPreviewView {
let view = VideoPreviewView()
view.backgroundColor = .black
view.videoPreviewLayer.cornerRadius = 0
view.videoPreviewLayer.session = session
view.videoPreviewLayer.connection?.videoOrientation = .portrait
return view
}
func updateUIView(_ uiView: VideoPreviewView, context: Context) {
}
}
I wish to add a binding property to this camera view that allows me to toggle this view in and out of my screen like any other social media app would allow. Here's an example
#State var showCamera: Bool = false
var body: some View {
mainTabView
.overlay {
CameraView(showCamera: $showCamera)
}
}
I understand that the code to achieve this must be written inside the updateUIView() method. Now, although I'm quite familiar with SwiftUI, I'm relatively inexperienced with UIKit, so any help on this and any helpful resources that could help me better code situations similar to this would be greatly appreciated.
Thank you.
EDIT: Made it clear that the first block of code is my CameraView.
EDIT2: Added Example of how I'd like to use the CameraView in my App.
Judging by the way you would like to use it in the app, the issue seems to not be with the CameraViewFinder but rather with the way in which you want to present it.
A proper SwiftUI way to achieve this would be to use a sheet like this:
#State var showCamera: Bool = false
var body: some View {
mainTabView
.sheet(isPresented: $showCamera) {
CameraView()
.interactiveDismissDisabled() // Disables swipe to dismiss
}
}
If you don't want to use the sheet presentation and would like to cover the whole screen instead, then you should use the .fullScreenCover() modifier like this.
#State var showCamera: Bool = false
var body: some View {
mainTabView
.overlay {
CameraView()
.fullScreenCover(isPresented: $showCamera)
}
}
Either way you would need to somehow pass the state to your CameraView to allow the presented screen to set the state to false and therefore dismiss itself, e.g. with a button press.

Custom CameraView bugs whole app when integrating it into my app

I have a custom camera view which uses UIKit to capture pictures and store it in a CameraViewModel in my SwiftUI project. The CameraPreview is what acts as the view Finder for my camera view and uses AVFoundation:
struct CameraPreview: UIViewRepresentable {
class VideoPreviewView: UIView {
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
var videoPreviewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
}
let session: AVCaptureSession
func makeUIView(context: Context) -> VideoPreviewView {
let view = VideoPreviewView()
view.backgroundColor = .black
view.videoPreviewLayer.cornerRadius = 0
view.videoPreviewLayer.session = session
view.videoPreviewLayer.connection?.videoOrientation = .portrait
return view
}
func updateUIView(_ uiView: VideoPreviewView, context: Context) {
}
}
and the I use this in my CameraView.swift body as such
#StateObject var model = CameraViewModel()
#State var currentZoomFactor: CGFloat = 1.0
#Binding var showCameraView: Bool
// MARK: [main body starts here]
var body: some View {
GeometryReader { reader in
ZStack {
// This black background lies behind everything.
Color.black.edgesIgnoringSafeArea(.all)
CameraPreview(session: model.session)
.onAppear {
model.configure()
}
.alert(isPresented: $model.showAlertError, content: {
Alert(title: Text(model.alertError.title), message: Text(model.alertError.message), dismissButton: .default(Text(model.alertError.primaryButtonTitle), action: {
model.alertError.primaryAction?()
}))
})
.overlay(
Group {
if model.willCapturePhoto {
Color.black
}
}
)
.scaledToFill()
.ignoresSafeArea()
.frame(width: reader.size.width,height: reader.size.height )
// .animation(.easeInOut)
VStack {
HStack {
Button {
//
} label: {
Image(systemName: "xmark")
.resizable()
.frame(width: 20, height: 20)
.tint(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
Spacer()
flashButton
}
HStack {
capturedPhotoThumbnail
Spacer()
captureButton
Spacer()
flipCameraButton
}
.padding([.horizontal, .bottom], 20)
.frame(maxHeight: .infinity, alignment: .bottom)
}
} // [ZStack Ends Here]
} // [Geometry Reader Ends here]
} // [Main Body Ends here]
I wish to open the camera View when someone presses a button somewhere on my app, like so
#State var showCamera: Bool = false
var body: some View {
mainTabView
.overlay {
CameraView(showCamera: $showCamera)
}
}
But when I do this in my app, no matter where I put the camera overlay, it stays open and the close button does nothing to close the camera view either. I'm pretty sure this is my fundamental lack of how views are constructed in UIKit and how the UIViewRepresentable works but I'd like some help regardless on how I'd achieve the desired effect. Also, any resources to understand how this works would also be greatly appreciated. Thank you.

Sticky section in SwiftUI scrollview

I'm trying to simulate sticky section header that PlainListStyle has in scrollView scenario:
struct MyScreen: View {
#State private var scrollViewOffset: CGFloat = .zero
#State var headerScrollView: CGRect = .zero
#State var headerTop: CGRect = .zero
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Static top content")
.frame(width: UIScreen.main.bounds.width, height: 50)
.background(Color.red)
Spacer()
}
ZStack(alignment: .top) {
scrollable
tabHeader
.background(Color.white)
.opacity(shouldSwap ? 1 : 0)
.rectReader($headerTop, in: .global)
}
}
.background(Color(hex: "#F7F7F7"))
}
var tabHeader: some View {
Text("My Header")
.frame(width: UIScreen.main.bounds.width, height: 50)
.background(Color.blue)
}
var topContent: some View {
Text("My scrollable content")
.frame(width: UIScreen.main.bounds.width, height: 200)
}
var scrollable: some View {
TrackableScrollView(contentOffset: $scrollViewOffset) {
VStack {
VStack {
topContent
tabHeader
.opacity(shouldSwap ? 0 : 1)
.rectReader($headerScrollView, in: .global)
}
HStack() {
// ... content
}
}
}
}
var shouldSwap: Bool {
return headerTop.origin.y >= headerScrollView.origin.y
}
}
I'm trying to achieve this by defining my tabHeader view at two places - inside scrollview and at top of scrollView (in ZStack with scrollView) - once headers overlap, I just show top one and hide one from inside the scrollView and it actually looks pretty good, just like list sticky section.
My question is: can I somehow reuse same view to be animated/translated/updated in those two locations (inside scrollView -> swipeUp -> static above scrollView -> swipe down -> inside scrollView), because right now, if there is any internal state in tabHeader, it won't work because those are 2 different views so all the state they have must be in their parent and passed as binding.
If that's not possible, can I somehow restrict view from having internal state by implementing some protocol or something?

Size a SwiftUI view to be safeAreaInsets.top + 'x'

I am trying to create a full bleed SwiftUI 'header' view in my scene. This header will sit within a List or a scrollable VStack.
In order to do this, I'd like to have my text in the header positioned below the safe area, but the full view should extend from the top of the screen (and thus, overlap the safe area). Here is visual representation:
V:[(safe-area-spacing)-(padding)-(text)]
here is my attempt:
struct HeaderView: View {
#State var spacing: CGFloat = 100
var body: some View {
HStack {
VStack(alignment: .leading) {
Rectangle()
.frame(height: spacing)
.opacity(0.5)
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer()
}
.frame(maxWidth: .infinity)
.background(Color.red)
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: SafeAreaSpacingKey.self,
value: proxy.safeAreaInsets.top
)
}
)
.onPreferenceChange(SafeAreaSpacingKey.self) { value in
self.spacing = value
}
}
}
This however, does not seem to correctly size 'Rectangle'. How can I size a view according to the safe area?
Is this what you're looking for? I try to avoid using GeometryReader unless you really need it... I created a MainView, which has a background and a foreground layer. The background layer will ignore the safe areas (full bleed) but the foreground will stay within the safe area by default.
struct HeaderView: View {
var body: some View {
HStack {
VStack(alignment: .leading) {
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer(minLength: 0)
}
}
}
struct MainView: View {
var body: some View {
ZStack {
// Background
ZStack {
}
.frame(maxWidth:. infinity, maxHeight: .infinity)
.background(Color.red)
.edgesIgnoringSafeArea(.all)
// Foreground
VStack {
HeaderView()
Spacer()
}
}
}
}
add an state to store desired height
#State desiredHeight : CGFloat = 0
then on views body :
.onAppear(perform: {
if let window = UIApplication.shared.windows.first{
let phoneSafeAreaTopnInset = window.safeAreaInsets.top
desiredHeight = phoneSafeAreaTopnInset + x
}
})
set the desiredHeight for your view .
.frame(height : desiredHeight)

In SwiftUI how can I animate a button offset when displayed

In SwiftUI, I want a button to appear from off screen by dropping in from the top into a final position when the view is initially displayed, I'm not asking for animation when the button is pressed.
I have tried:
Button(action: {}) {
Text("Button")
}.offset(x: 0.0, y: 100.0).animation(.basic(duration: 5))
but no joy.
If you would like to play with offset, this can get you started.
struct ContentView : View {
#State private var offset: Length = 0
var body: some View {
Button(action: {}) { Text("Button") }
.offset(x: 0.0, y: offset)
.onAppear {
withAnimation(.basic(duration: 5)) { self.offset = 100.0 }
}
}
}
I first suggested a .transition(.move(.top)), but I am updating my answer. Unless your button is on the border of the screen, it may not be a good fit. The move is limited to the size of the moved view. So you may need to use offset after all!
Note that to make it start way out of the screen, the initial value of offset can be negative.
First of all you need to create a transition. You could create an extension for AnyTransition or just create a variable. Use the move() modifier to tell the transition to move the view in from a specific edge
let transition = AnyTransition.move(edge: .top);
This alone only works if the view is at the edge of the screen. If your view is more towards the center you can use the combined() modifier to combine another transition such as offset() to add additional offset
let transition = AnyTransition
.move(edge: .top)
.combined(with:
.offset(
.init(width: 0, height: 100)
)
);
This transition will be for both showing and removing a view although you can use AnyTransition.asymmetric() to use different transitions for showing and removing a view
Next create a showButton bool (name this whatever) which will handle showing the button. This will use the #State property wrapper so SwiftUI will refresh the UI when changed.
#State var showButton: Bool = false;
Next you need to add the transition to your button and wrap your button within an if statement checking if the showButton bool is true
if (self.showButton == true) {
Button(action: { }) {
Text("Button")
}
.transition(transition);
}
Finally you can update the showButton bool to true or false within an animation block to animate the button transition. toggle() just reverses the state of the bool
withAnimation {
self.showButton.toggle();
}
You can put your code in onAppear() and set the bool to true so the button is shown when the view appears. You can call onAppear() on most things like a VStack
.onAppear {
withAnimation {
self.showButton = true;
}
}
Check the Apple docs to see what is available for AnyTransition https://developer.apple.com/documentation/swiftui/anytransition
Presents a message box on top with animation:
import SwiftUI
struct MessageView: View {
#State private var offset: CGFloat = -200.0
var body: some View {
VStack {
HStack(alignment: .center) {
Spacer()
Text("Some message")
.foregroundColor(Color.white)
.font(Font.system(.headline).bold())
Spacer()
}.frame(height: 100)
.background(Color.gray.opacity(0.3))
.offset(x: 0.0, y: self.offset)
.onAppear {
withAnimation(.easeOut(duration: 1.5)) { self.offset = 000.0
}
}
Spacer()
}
}
}
For those that do want to start from a Button that moves when you tap on it, try this:
import SwiftUI
struct ContentView : View {
#State private var xLoc: CGFloat = 0
var body: some View {
Button("Tap me") {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
}.offset(x: xLoc, y: 0.0)
}
}
Or alternatively (can replace Text with anything):
Button(action: {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
} )
{ Text("Tap me") }.offset(x: xLoc, y: 0.0)