I have a series of images in a ForEach loop. Tapping an image will select it and with draw a red overlay. However when tapping on the the second image the third image gets selected. If I replace the images with simple shapes the code behaves as expected. Please see attached screen cast.
onTapGesture is set on an individual tile.
Here is the code:
struct ContentView: View {
let tiles = [TileClass(counter: 1 ), TileClass(counter: 2 ), TileClass(counter: 3 )]
var body: some View {
TilesView(tiles: tiles )
}
}
struct TileView: View {
#StateObject var tile:TileClass
var body: some View {
Image( tile.imageName )
.resizable()
.scaledToFill()
.overlay( tile.isSelected ? Color.red.opacity(0.5) : .clear )
.frame( height: 150 )
.cornerRadius(10)
.padding( .horizontal )
.padding( .vertical, 5 )
.clipped()
.onTapGesture {
tile.isSelected.toggle()
}
}
}
struct TilesView: View {
var tiles:[ TileClass ]
var body: some View {
VStack{
ForEach( tiles ){ tile in
TileView(tile: tile )
}
}
}
}
class TileClass: Identifiable, Equatable, ObservableObject, Hashable{
static func == (lhs: TileClass, rhs: TileClass) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var id:UUID = UUID()
#Published var isSelected:Bool = false
var imageName:String!
init( counter:Int ) {
imageName = "img\(counter)"
}
}
Any Help will be appreciated.
You are not using ObservedObject the way it is meant to be used.
More info on how to use them can be found here: https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
Try this approach using an ObservedObject (TilesModel) to hold and update your tiles and
declaring a struct Tile to represent the object. Works well for me.
Note, your code does work if you use .scaledToFit() instead of .scaledToFill() in your TileView, or put .frame(height: 150 ) before .scaledToFill(). However just because it works does not mean it is right.
struct ContentView: View {
#StateObject var tilesModel = TilesModel() // <-- here
var body: some View {
TilesView(tilesModel: tilesModel) // <-- here
}
}
struct TilesView: View {
#ObservedObject var tilesModel: TilesModel // <-- here
var body: some View {
VStack{
ForEach($tilesModel.tiles) { $tile in
TileView(tile: $tile )
}
}
}
}
struct TileView: View {
#Binding var tile: Tile // <-- here
var body: some View {
Image(tile.imageName)
.resizable()
.frame( height: 150 ) // <-- here
.scaledToFill()
.overlay( tile.isSelected ? Color.red.opacity(0.5) : .clear )
.cornerRadius(10)
.padding( .horizontal )
.padding( .vertical, 5 )
.clipped()
.onTapGesture {
tile.isSelected.toggle()
}
}
}
class TilesModel: ObservableObject{
#Published var tiles = [Tile(counter: 1), Tile(counter: 2), Tile(counter: 3)]
}
struct Tile: Identifiable {
var id:UUID = UUID()
var isSelected: Bool = false
var imageName: String = ""
init(counter: Int) {
self.imageName = "img\(counter)"
}
}
This appears to be a system bug when using scaledToFill(). The tap gesture seems to be picking up the full image rect, including the area not drawn. Hence in my case, using a portrait orientated image will cause unexpected behaviours. As was suggested in the following post:
SwiftUI tap gesture selecting wrong item
...to work around this issue one needs to add a .contentShape modifier which determines the area to receive the tap gesture.
Simply adding .contentShape(Rectangle()) before .onTapGesture resolved the issue for me.(as suggested by: #workingdog support Ukraine)
struct TileView: View {
#Binding var tile:Tile
var body: some View {
Image( tile.imageName )
.resizable()
.scaledToFill()
.overlay( tile.isSelected ? Color.red.opacity(0.5) : .clear )
.frame( height: 150 )
.cornerRadius(10)
.padding( .horizontal )
.padding( .vertical, 5 )
.contentShape( Rectangle())
.onTapGesture {
tile.isSelected.toggle()
}
}
}
Any other suggestions and improvements will be appreciated.
Related
I have a view with an infinite animation. These views are added to a VStack, as follows:
struct PanningImage: View {
let systemName: String
#State private var zoomPadding: CGFloat = 0
var body: some View {
VStack {
Spacer()
Image(systemName: self.systemName)
.resizable()
.aspectRatio(contentMode: .fill)
.padding(.leading, -100 * self.zoomPadding)
.frame(maxWidth: .infinity, maxHeight: 200)
.clipped()
.padding()
.border(Color.gray)
.onAppear {
let animation = Animation.linear.speed(0.5).repeatForever()
withAnimation(animation) {
self.zoomPadding = abs(sin(zoomPadding + 10))
}
}
Spacer()
}
.padding()
}
}
struct ContentView: View {
#State private var imageNames: [String] = []
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(self.imageNames, id: \.self) { imageName in
PanningImage(systemName: imageName)
}
// Please uncomment to see the problem
// .animation(.default)
// .transition(.move(edge: .top))
}
}
.toolbar(content: {
Button("Add") {
self.imageNames.append("photo")
}
})
}
}
}
Observe how adding a row to the VStack can be animated, by uncommenting the lines in ContentView.
The problem is that if an insertion into the list is animated, the "local" infinite animation no longer works correctly. My guess is that the ForEach animation is applied to each child view, and somehow these animations influence each other. How can I make both animations work?
The issue is using the deprecated form of .animation(). Be careful ignoring deprecation warnings. While often they are deprecated in favor of a new API that works better, etc. This is a case where the old version was and is, broken. And what you are seeing is as a result of this. The fix is simple, either use withAnimation() or .animation(_:value:) instead, just as the warning states. An example of this is:
struct ContentView: View {
#State private var imageNames: [String] = []
#State var isAnimating = false // You need another #State var
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(self.imageNames, id: \.self) { imageName in
PanningImage(systemName: imageName)
}
// Please uncomment to see the problem
.animation(.default, value: isAnimating) // Use isAnimating
.transition(.move(edge: .top))
}
}
.toolbar(content: {
Button("Add") {
imageNames.append("photo")
isAnimating = true // change isAnimating here
}
})
}
}
}
The old form of .animation() had some very strange side effects. This was one.
Foreach on view must be presented with a View to process.
struct Home : View {
private var numberOfImages = 3
#State var isPresented : Bool = false
#State var currentImage : String = ""
var body: some View {
VStack {
TabView {
ForEach(1..<numberOfImages+1, id: \.self) { num in
Image("someimage")
.resizable()
.scaledToFill()
.onTapGesture() {
currentImage = "top_00\(num)"
isPresented.toggle()
}
}
}.fullScreenCover(isPresented: $isPresented, content: {FullScreenModalView(imageName: currentImage) } )
}
}
I'm trying to display an image in fullScreenCover. My problem is that the first image is empty. Yes, we can solve this defining at the beginning, however, this will complicate the code according to my experiences.
My question is, is it possible to assign a value to currentImage before the onTapGesture processed.
In short, what is the good practice here.
What you need is to use this modifier to present your full screen modal:
func fullScreenCover<Item, Content>(item: Binding<Item?>, onDismiss: (() -> Void)? = nil, content: #escaping (Item) -> Content) -> some View where Item : Identifiable, Content : View
You pass in a binding to an optional and uses the non optional value to construct a destination:
struct ContentView: View {
private let imageNames = ["globe.americas.fill", "globe.europe.africa.fill", "globe.asia.australia.fill"]
#State var selectedImage: String?
var body: some View {
VStack {
TabView {
ForEach(imageNames, id: \.self) { imageName in
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding()
.onTapGesture() {
selectedImage = imageName
}
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.fullScreenCover(item: $selectedImage) { imageName in
Destination(imageName: imageName)
}
}
}
}
struct Destination: View {
let imageName: String
var body: some View {
ZStack {
Color.blue
Image(
systemName: imageName
)
.resizable()
.scaledToFit()
.foregroundColor(.green)
}
.edgesIgnoringSafeArea(.all)
}
}
You will have to make String identifiable for this example to work (not recommended):
extension String: Identifiable {
public var id: String { self }
}
Building upon #LuLuGaGa’s answer (accept that answer, not this one), instead of making String Identifiable, create a new Identifiable struct to hold the image info. This guarantees each image will have a unique identity, even if they use the same base image name. Also, the ForEach loop now becomes ForEach(imageInfos) since the array contains Identifiable objects.
Use map to turn image name strings into [ImageInfo] by calling the ImageInfo initializer with each name.
This example also puts the displayed image into its own view which can be dismissed with a tap.
import SwiftUI
struct ImageInfo: Identifiable {
let name: String
let id = UUID()
}
struct ContentView: View {
private let imageInfos = [
"globe.americas.fill",
"globe.europe.africa.fill",
"globe.asia.australia.fill"
].map(ImageInfo.init)
#State var selectedImage: ImageInfo?
var body: some View {
VStack {
TabView {
ForEach(imageInfos) { imageInfo in
Image(systemName: imageInfo.name)
.resizable()
.scaledToFit()
.padding()
.onTapGesture() {
selectedImage = imageInfo
}
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.fullScreenCover(item: $selectedImage) { imageInfo in
ImageDisplay(info: imageInfo)
}
}
}
}
struct ImageDisplay: View {
let info: ImageInfo
#Environment(\.dismiss) var dismiss
var body: some View {
ZStack {
Color.blue
Image(
systemName: info.name
)
.resizable()
.scaledToFit()
.foregroundColor(.green)
}
.edgesIgnoringSafeArea(.all)
.onTapGesture {
dismiss()
}
}
}
I'm trying to use a LazyVGrid in SwiftUI where you can touch and drag your finger to select multiple adjacent cells in a specific order. This is not a drag and drop and I don't want to move the cells (maybe drag isn't the right term here, but couldn't think of another term to describe it). Also, you would be able to reverse the selection (ie: each cell can only be selected once and reversing direction would un-select the cell). How can I accomplish this? Thanks!
For example:
struct ContentView: View {
#EnvironmentObject private var cellsArray: CellsArray
var body: some View {
VStack {
LazyVGrid(columns: gridItems, spacing: spacing) {
ForEach(0..<(rows * columns), id: \.self){index in
VStack(spacing: 0) {
CellsView(index: index)
}
}
}
}
}
}
struct CellsView: View {
#State var index: Int
#EnvironmentObject var cellsArray: CellsArray
var body: some View {
ZStack {
Text("\(self.cellsArray[index].cellValue)") //cellValue is a string
.foregroundColor(Color.yellow)
.frame(width: getWidth(), height: getWidth())
.background(Color.gray)
}
//.onTapGesture ???
}
func getWidth()->CGFloat{
let width = UIScreen.main.bounds.width - 10
return width / CGFloat(columns)
}
}
Something like this might help, the only issue here is the coordinate space. Overlay is not drawing the rectangle in the correct coordinate space.
struct ImagesView: View {
var columnSize: CGFloat
#Binding var projectImages: [ProjectImage]
#State var selectedImages: [ImageSelection] = []
#State var dragWidth: CGFloat = 1.0
#State var dragHeight: CGFloat = 1.0
#State var dragStart: CGPoint = CGPoint(x:0, y:0)
let columns = [
GridItem(.adaptive(minimum: 200), spacing: 0)
]
var body: some View {
GeometryReader() { geometry in
ZStack{
ScrollView{
LazyVGrid(columns: [
GridItem(.adaptive(minimum: columnSize), spacing: 2)
], spacing: 2){
ForEach(projectImages, id: \.imageUUID){ image in
Image(nsImage: NSImage(data: image.imageData)!)
.resizable()
.scaledToFit()
.border(selectedImages.contains(where: {imageSelection in imageSelection.uuid == image.imageUUID}) ? Color.blue : .primary, width: selectedImages.contains(where: {imageSelection in imageSelection.uuid == image.imageUUID}) ? 5.0 : 1.0)
.gesture(TapGesture(count: 2).onEnded{
print("Double tap finished")
})
.gesture(TapGesture(count: 1).onEnded{
if selectedImages.contains(where: {imageSelection in imageSelection.uuid == image.imageUUID}) {
print("Image is already selected")
if let index = selectedImages.firstIndex(where: {imageSelection in imageSelection.uuid == image.imageUUID}){
selectedImages.remove(at: index)
}
} else {
selectedImages.append(ImageSelection(imageUUID: image.imageUUID))
print("Image has been selected")
}
})
}
}
.frame(minHeight: 50)
.padding(.horizontal, 1)
}
.simultaneousGesture(
DragGesture(minimumDistance: 2)
.onChanged({ value in
print("Drag Start: \(value.startLocation.x)")
self.dragStart = value.startLocation
self.dragWidth = value.translation.width
self.dragHeight = value.translation.height
})
)
.overlay{
Rectangle()
.frame(width: dragWidth, height: dragHeight)
.offset(x:dragStart.x, y:dragStart.y)
}
}
}
}
}
So I am trying to make my TabView height dynamic. I have been looking for a way to do this but I can't seem to find a solution anywhere. This is how my code looks like.
struct ContentView: View {
#State var contentHeight: CGFloat = 0
var body: some View {
NavigationView {
ScrollView {
VStack {
TabView {
TestView1(contentHeight: $contentHeight)
TestView2(contentHeight: $contentHeight)
}
.tabViewStyle(.page)
.frame(height: contentHeight)
.indexViewStyle(.page(backgroundDisplayMode: .always))
.background(.yellow)
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Test Project")
}
}
}
}
This is how my test1view and test2view look like.
struct TestView1: View {
#State var height: CGFloat = 0
#Binding var contentHeight: CGFloat
var body: some View {
Color.red
.frame(maxWidth:.infinity, minHeight: 200, maxHeight: 200)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: HeightPreferenceKey.self,
value: geo.size.height
)
.onAppear {
contentHeight = height
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
self.height = height
}
)
}
}
struct TestView2: View {
#Binding var contentHeight: CGFloat
#State var height: CGFloat = 0
var body: some View {
Color.black
.frame(maxWidth:.infinity, minHeight: 350, maxHeight: 350)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: HeightPreferenceKey.self,
value: geo.size.height
)
.onAppear {
contentHeight = height
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
self.height = height
}
)
}
}
struct HeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Now the problem is that when I drag it just a little the height changes. So when I drag it a little to the left the height changes to the height of TestView2 and it is still on TestView1.
I tried to add a drag gesture but it didn't let me swipe to the next page. So I don't know how I will be able to achieve this. Ive been looking for a solution but still no luck.
You can use the TabView($selection) initializer to do this. https://developer.apple.com/documentation/swiftui/tabview/init(selection:content:)
It tells you which tab you're currently viewing. Based off the middle point of the screen. And you don't have to deal with nasty GeometryReader and HeightPreferenceKey.
Here's your updated code. I even added a nice animation to fade between the two heights!
struct ContentView: View {
#State var selectedTab: Tab = .first
#State var animatedContentHeight: CGFloat = 300
enum Tab {
case first
case second
var contentHeight: CGFloat {
switch self {
case .first:
return 200
case .second:
return 350
}
}
}
var body: some View {
TabView(selection: $selectedTab) {
TestView1()
.tag(Tab.first)
TestView2()
.tag(Tab.second)
}
.tabViewStyle(.page)
// .frame(height: selectedTab.contentHeight) // Uncomment to see without animation
.frame(height: animatedContentHeight)
.indexViewStyle(.page(backgroundDisplayMode: .always))
.onChange(of: selectedTab) { newValue in
print("now selected:", newValue)
withAnimation { animatedContentHeight = selectedTab.contentHeight }
}
}
}
struct TestView1: View {
var body: some View {
Color.red
}
}
struct TestView2: View {
var body: some View {
Color.black
}
}
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