SwiftUI drag and drop on moving/animating view/shape - swiftui

I am trying to implement drag and drop feature in SwiftUI. It seems when view is not animating the following code works fine. But when I am moving the view with offset then there is some issue with drag location and view like view location is not same as touch location and view shape is not same as original.
Dragging without offset animation(animate = false) works fine:
Dragging with offset animation(animate = true):
Code Snippet:
import SwiftUI
struct ContentView: View {
#ObservedObject var dropDelegate = CircleDropDelegate()
#State var animate = false
var body: some View {
VStack(alignment: .leading) {
Spacer()
Circle()
.fill(Color.green)
.offset(x: animate ? 100 : 0)
.animation(Animation.linear(duration: 5).repeatForever(autoreverses: true))
.frame(width: 50, height: 50)
.onDrag({
return NSItemProvider(object: "circle" as NSString)
})
Spacer()
Rectangle()
.fill(Color.green)
.frame(height: 150)
.onDrop(of: ["public.text"], delegate: dropDelegate)
Spacer()
}
.onAppear {
animate = true
}
.padding()
.background(Color.init(white: 0.7))
}
}
class CircleDropDelegate: ObservableObject, DropDelegate {
#Published var isEntered: Bool = false
func performDrop(info: DropInfo) -> Bool {
print("perform drop on box \(info)")
return true
}
func dropEntered(info: DropInfo) {
print("circle entered the box")
isEntered = true
}
func dropExited(info: DropInfo) {
print("circle exited the box")
isEntered = false
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Is there a way to fix this issue?

Related

How to make a left vertical tabView on SwiftUI TVOS?

Whenever a tabButton is highlighted, I wanna show the corresponding content on the RightMainView, I can use a #Published property on ViewModel to do that, but the problem is that the same RightMainView will be redraw while switching tabs.
The MainView will be a complicated UI and also has focus engine, so I definitely do not want the MainView redraw.
import SwiftUI
struct Model: Identifiable, Equatable {
let id = UUID()
let title: String
static func == (lhs: Model, rhs: Model) -> Bool {
return lhs.title == rhs.title
}
}
class ViewModel: ObservableObject {
let titles = [Model(title: "Home"), Model(title: "live"), Model(title: "setting"), Model(title: "network")]
#Published var selected: Model = Model(title: "Home")
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
HStack(spacing: 0) {
leftTab
.focusSection()
rightMainView
}.edgesIgnoringSafeArea(.all)
}
private var leftTab: some View {
VStack {
ForEach(viewModel.titles, id: \.self.id) { title in
ZStack {
TabButton(viewModel: viewModel, title: title)
}.focusable()
}
}
.frame(maxWidth: 400, maxHeight: .infinity)
.background(.yellow)
}
private var rightMainView: some View {
VStack {
let _ = print("Redrawing the View")
Text(viewModel.selected.title)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.red)
}
}
}
struct TabButton: View {
#Environment(\.isFocused) var isFocused
let viewModel: ViewModel
let title: Model
var body: some View {
Text(title.title)
.frame(width: 200)
.padding(30)
.background(isFocused ? .orange : .white)
.foregroundColor(isFocused ? .black : .gray)
.cornerRadius(20)
.onChange(of: isFocused) { newValue in
if newValue {
viewModel.selected = title
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here are some other things I have tried:
I tried the native TabView, https://developer.apple.com/documentation/swiftui/tabview, the View does not redrew while switch tabs, but it only support horizontal, not left vertical model, is there a way I can use native TabView to implement my vertical tabView (with both text and images)
I tried the NavigationSplitView as well, https://developer.apple.com/documentation/swiftui/navigationsplitview, the behavior is not the same on TVOS with iPad tho. On TVOS only the TabButtons are showing, the MainView/details are not showing

Hide NavigationBar back button and reduce space

I'm trying to hide the Back button above my Navigation title, But .navigationBarBackButtonHidden(true) isn't working, Is there any alternative way I can approach it to remove this back button and the space below it and my heading ?
import SwiftUI
struct ContentView: View {
init() {
let navBarAppearance = UINavigationBar.appearance()
navBarAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.orange]
navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.orange]
}
#AppStorage("hasOpened") private var hasOpened : Bool = false
var body: some View {
NavigationView(){
VStack(alignment: .leading, spacing: 0) {
Text("Hello, World!")
.onAppear{
hasOpened = false
}
}
.navigationBarBackButtonHidden(true)
.padding()
.navigationBarTitle("Home")
.foregroundColor(Color("Orange"))
.navigationBarItems(trailing:
Image("Logo")
.resizable()
.scaledToFit()
.frame(width: 100.0,height:100)
.padding(.top, 75)
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Output:

Having user add multiple Images to SwiftUI view

I am practicing with SwiftUI and making a meme maker which has labels that are produced from a textField and can be moved and resized. I also want to be able to do this with images from the users Photo library. I am able to get one image, but if I try and get more it just replaces the first image. I tried having the images added to an array, but then the images will not show up on the memeImageView.
Image property
#State private var image = UIImage()
Button
Button {
self.isShowPhotoLibrary = true
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
MemeUmageView
var memeImageView: some View {
ZStack {
KFImage(URL(string: meme.url ?? ""))
.placeholder {
ProgressView()
}
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: UIScreen.main.bounds.height / 2.5)
ForEach(addedLabels, id:\.self) { label in
DraggableLabel(text: label)
}
DraggableImage(image: image)
}
.clipped()
}
Attempt with using an array. I also tried making three buttons to add up to three images, each as its own property thinking that the initial property was being overridden.
My image array
#State private var addedImages = [UIImage?]()
Button
Button {
self.isShowPhotoLibrary = true
addedImages.append(image)
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
var memeImageView: some View {
ZStack {
KFImage(URL(string: meme.url ?? ""))
.placeholder {
ProgressView()
}
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: UIScreen.main.bounds.height / 2.5)
ForEach(addedLabels, id:\.self) { label in
DraggableLabel(text: label)
}
ForEach(0..<addedImages.count) { index in
DraggableImage(image: addedImages[index]!)
}
}
.clipped()
}
Where I call MemeImageView.
var body: some View {
VStack(spacing: 12) {
memeImageView
ForEach(0..<(meme.boxCount ?? 0)) { i in
TextField("Statement \(i + 1)", text: $addedLabels[i])
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.25))
.cornerRadius(5)
.onTapGesture {
self.endEditing()
}
}
.padding(.horizontal)
}.onTapGesture {
self.endEditing()
}
// Gets a new Image
Button {
self.isShowPhotoLibrary = true
addedImages.append(image)
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
Spacer()
// Saves Image
Button {
// takes a screenshot and crops it
if let image = memeImageView.takeScreenshot(origin: CGPoint(x: 0, y: UIApplication.shared.windows[0].safeAreaInsets.top + navBarHeight + 1), size: CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height / 2.5)) {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
presentationMode.wrappedValue.dismiss() // dismisses the view
}
}
label: {
Text("Save image")
.foregroundColor(Color.yellow)
}.frame( width: 150, height: 50)
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.red, lineWidth: 3)
)
.navigationBarTitle(meme.name ?? "Meme", displayMode: .inline)
.background(NavBarAccessor { navBar in
self.navBarHeight = navBar.bounds.height
})
}
For Reproducing(as close to how mine actual project is setup):
Content View
import SwiftUI
struct ContentView: View {
var body: some View {
DragImageView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
DragImageView:
import SwiftUI
struct DragImageView: View {
//===================
// MARK: Properties
//===================
#State private var addedImages = [UIImage?]()
#State private var isShowPhotoLibrary = false
#State private var image = UIImage()
var body: some View {
VStack(spacing: 12) {
imageView
}
// Gets a new Image
Button {
self.isShowPhotoLibrary = true
addedImages.append(image)
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: self.$image)
}
Spacer()
}
var imageView: some View {
ZStack {
DraggableImage(image: image)
}
//.clipped()
}
// This will dismiss the keyboard
private func endEditing() {
UIApplication.shared.endEditing()
}
}
// Allows fot the keyboard to be dismissed
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
DraggableImage:
import SwiftUI
struct DraggableImage: View {
// Drag Gesture
#State private var currentPosition: CGSize = .zero
#State private var newPosition: CGSize = .zero
// Roation Gesture
#State private var rotation: Double = 0.0
// Scale Gesture
#State private var scale: CGFloat = 1.0
// The different states the frame of the label could be
private enum WidthState: Int {
case full, half, third, fourth
}
#State private var widthState: WidthState = .full
#State private var currentWidth: CGFloat = 100 //UIScreen.main.bounds.width
var image: UIImage
var body: some View {
VStack {
Image(uiImage: self.image)
.resizable()
.scaledToFill()
.frame(width: self.currentWidth)
.lineLimit(nil)
}
.scaleEffect(scale) // Scale based on our state
.rotationEffect(Angle.degrees(rotation)) // Rotate based on the state
.offset(x: self.currentPosition.width, // Offset from the drag difference from it's current position
y: self.currentPosition.height)
.gesture(
// Two finger rotation
RotationGesture()
.onChanged { angle in
self.rotation = angle.degrees // keep track of the angle for state
}
// We want it to work with the scale effect, so they could either scale and rotate at the same time
.simultaneously(with:
MagnificationGesture()
.onChanged { scale in
self.scale = scale.magnitude // Keep track of the scale
})
// Update the drags new position to be wherever it was last dragged to. (we don't want to reset it back to it's current position)
.simultaneously(with: DragGesture()
.onChanged { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width,
height: value.translation.height + self.newPosition.height)
}
.onEnded { value in
self.newPosition = self.currentPosition
})
)
/// Have to do double tap first or else it will never work with the single tap
.onTapGesture(count: 2) {
// Update our widthState to be the next on in the 'enum', or start back at .full
self.widthState = WidthState(rawValue: self.widthState.rawValue + 1) ?? .full
self.currentWidth = UIScreen.main.bounds.width / CGFloat(self.widthState.rawValue)
}
}
}
ImagePicker:
import UIKit
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
var sourceType: UIImagePickerController.SourceType = .photoLibrary
#Binding var selectedImage: UIImage
#Environment(\.presentationMode) private var presentationMode
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
imagePicker.allowsEditing = false
imagePicker.sourceType = sourceType
imagePicker.delegate = context.coordinator
return imagePicker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
parent.selectedImage = image
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
I should add this is to make memes, so the user picked images go on top the view that I save to the camera roll.
I'm not 100% clear on what the exact desired output should be, but this should get you started (explained below):
struct DragImageView: View {
//===================
// MARK: Properties
//===================
#State private var addedImages = [UIImage]()
#State private var isShowPhotoLibrary = false
var bindingForImage: Binding<UIImage> {
Binding<UIImage> { () -> UIImage in
return addedImages.last ?? UIImage()
} set: { (newImage) in
addedImages.append(newImage)
print("Images: \(addedImages.count)")
}
}
var body: some View {
VStack(spacing: 12) {
imageView
}
// Gets a new Image
Button {
self.isShowPhotoLibrary = true
} label: {
Text("Add Image")
.foregroundColor(Color.yellow)
}.sheet(isPresented: $isShowPhotoLibrary) {
ImagePicker(sourceType: .photoLibrary, selectedImage: bindingForImage)
}
Spacer()
}
var imageView: some View {
VStack {
ForEach(addedImages, id: \.self) { image in
DraggableImage(image: image)
}
}
}
// This will dismiss the keyboard
private func endEditing() {
UIApplication.shared.endEditing()
}
}
addedImages is now an array of non-optional UIImages
There's a custom Binding for the image picker. When it receives a new image, it appends it to the end of the array.
In var imageView, there's a VStack instead of a ZStack so that multiple images can get displayed (instead of stacked on top of each other) and a ForEach loop to iterate through the images.

How to stop SwiftUI DragGesture from animating subviews

I'm building a custom modal and when I drag the modal, any subviews that have animation's attached, they animate while I'm dragging. How do I stop this from happening?
I thought about passing down an #EnvironmentObject with a isDragging flag, but it's not very scalable (and doesn't work well with custom ButtonStyles)
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
.showModal(isShowing: .constant(true))
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
// I want this to not animate when dragging the modal
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(.spring())
}
}
UPDATE:
extension View {
func animationsDisabled(_ disabled: Bool) -> some View {
transaction { (tx: inout Transaction) in
tx.animation = tx.animation
tx.disablesAnimations = disabled
}
}
}
Container()
.animationsDisabled(isDragging || bottomState > 0)
In real life the Container contains a button with an animation on its pressed state
struct MyButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.spring())
}
}
Added the animationsDisabled function to the child view which does in fact stop the children moving during the drag.
What it doesn't do is stop the animation when the being initially slide in or dismissed.
Is there a way to know when a view is essentially not moving / transitioning?
Theoretically SwiftUI should not translate animation in this case, however I'm not sure if this is a bug - I would not use animation in Container in that generic way. The more I use animations the more tend to join them directly to specific values.
Anyway... here is possible workaround - break animation visibility by injecting different hosting controller in a middle.
Tested with Xcode 12 / iOS 14
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
Color.clear
if isShowing {
HelperView {
Container()
.background(Color.red)
}
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
Color.clear
}
}
}
}
struct HelperView<Content: View>: UIViewRepresentable {
let content: () -> Content
func makeUIView(context: Context) -> UIView {
let controller = UIHostingController(rootView: content())
return controller.view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
In Container, declare a binding var so you can pass the bottomState to the Container View:
struct Container: View {
#Binding var bottomState: CGFloat
.
.
.
.
}
Dont forget to pass bottomState to your Container View wherever you use it:
Container(bottomState: $bottomState)
Now in your Container View, you just need to declare that you don't want an animation while bottomState is being changed:
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(nil, value: bottomState) // You Need To Add This
.animation(.spring())
In .animation(nil, value: bottomState), by nil you are asking SwiftUI for no animations, while value of bottomState is being changed.
This approach is tested using Xcode 12 GM, iOS 14.0.1.
You must use the modifiers of the Text in the order i put them. that means that this will work:
.animation(nil, value: bottomState)
.animation(.spring())
but this won't work:
.animation(.spring())
.animation(nil, value: bottomState)
I also made sure that adding .animation(nil, value: bottomState) will only disable animations when bottomState is being changed, and the animation .animation(.spring()) should always work if bottomState is not being changed.
So this is my updated answer. I don't think there is a pretty way to do it so now I am doing it with a custom Button.
import SwiftUI
struct ContentView: View {
#State var isShowing = false
var body: some View {
Text("Hello, world!")
.padding()
.onTapGesture(count: 1, perform: {
withAnimation(.spring()) {
self.isShowing.toggle()
}
})
.showModal(isShowing: self.$isShowing)
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
#State var isDragging = false
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
isDragging = true
bottomState = value.translation.height
}
.onEnded { _ in
isDragging = false
if bottomState > 50 {
withAnimation(.spring()) {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
CustomButton(action: {}, label: {
Text("Pressme")
})
.frame(maxWidth: .infinity, maxHeight: 200)
}
}
struct CustomButton<Label >: View where Label: View {
#State var isPressed = false
var action: () -> ()
var label: () -> Label
var body: some View {
label()
.scaleEffect(self.isPressed ? 0.9 : 1.0)
.gesture(DragGesture(minimumDistance: 0).onChanged({_ in
withAnimation(.spring()) {
self.isPressed = true
}
}).onEnded({_ in
withAnimation(.spring()) {
self.isPressed = false
action()
}
}))
}
}
The problem is that you can't use implicit animations inside the container as they will be animated when it moves. So you need to explicitly set an animation using withAnimation also for the button pressed, which I now did with a custom Button and a DragGesture.
It is the difference between explicit and implicit animation.
Take a look at this video where this topic is explored in detail:
https://www.youtube.com/watch?v=3krC2c56ceQ&list=PLpGHT1n4-mAtTj9oywMWoBx0dCGd51_yG&index=11

How do I change button backgroundcolor if the button is disabled in swiftUI

I'm trying to create a buttonStyle that has a different background color if the button is disabled.
How do I do that?
I've created the code below to react to a variable that I've introduced myself, but is it possible to have it react on the buttons .disabled() state?
My code:
struct MyButtonStyle: ButtonStyle {
var enabledState = false
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.foregroundColor(Color.white)
.padding(10)
.padding(.horizontal, 20)
.background(self.enabledState ? Color(UIColor.orange) : Color(UIColor.lightGray))
.cornerRadius(20)
.frame(minWidth: 112, idealWidth: 112, maxWidth: .infinity, minHeight: 40, idealHeight: 40, maxHeight: 40, alignment: .center)
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
}
}
struct ContentView: View {
#State private var buttonEnabled = false
var body: some View {
HStack {
Button("Button") {
self.buttonEnabled.toggle()
print("Button pressed")
}
}
.buttonStyle(MyButtonStyle(enabledState: self.buttonEnabled))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
To track .disabled there is EnvironmentValues.isEnabled that shows this state. But environment values are applicable only to views and do not work in style.
So the solution is to create custom button that tracks isEnabled and pass it into own style.
Below is a demo of solution approach (MyButtonStyle is not changed). Tested with Xcode 12b.
struct MyButton: View {
let title: String
let action: () -> ()
#Environment(\.isEnabled) var isEnabled // to handle own state !!
init(_ title: String, action: #escaping () -> ()) {
self.title = title
self.action = action
}
var body: some View {
Button(title, action: action)
.buttonStyle(MyButtonStyle(enabledState: isEnabled))
}
}
struct ContentView: View {
#State private var buttonEnabled = true
var body: some View {
HStack {
MyButton("Button") { // << here !!
self.buttonEnabled.toggle()
print("Button pressed")
}
.disabled(!buttonEnabled) // << here !!
}
}
}
Again using the Environment.isEnabled but this time using a ViewModifier. This has the advantage that you can use it on other Views, not just Buttons. This implementation reduces the opacity of the button background color so no need for a new style to be injected.
struct MyButtonModifier: ViewModifier {
#Environment(\.isEnabled) var isEnabled
let backgroundColor: Color
func body(content: Content) -> some View {
content
.background(backgroundColor.opacity(isEnabled ? 1 : 0.5))
}
}
Then use it in your code as
Button("foo") {
// action
}
.modifier(MyButtonModifier(backgroundColor: Color.red))