SwiftUI: Kingfisher KFImage flashing images within LazyHStack - swiftui

KFImage within LazyHStask flashes image while dragging the view in spite of I set loadDiskFileSynchronously() to the view.
I suspect that KFImage loads image every time its onAppear() called, and it makes image flashing.
But it seems that KFImage preventing waste reloading image inside its onAppear().
https://github.com/onevcat/Kingfisher/blob/e30efd82368276664b9ffb8a10dc29cd6a6810da/Sources/SwiftUI/KFImageRenderer.swift#L75
How can I fix this?
This is sample snippet reproducing the problem.
struct SampleView : View {
let urls: [URL?]
#GestureState private var draggingOffset: CGFloat = 0
#State private var index: Int = 0
init(urls: [URL?]) {
self.urls = urls
}
var body: some View {
LazyHStack(spacing: 0) {
ForEach(urls.indices, id: \.self) { position in
KFImage(urls[position])
.resizable()
.loadDiskFileSynchronously()
.frame(width: UIScreen.main.bounds.width, alignment: .center)
.scaledToFit()
}
}
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height, alignment: .leading)
.offset(x: -CGFloat(index) * UIScreen.main.bounds.width)
.offset(x: draggingOffset)
.animation(.interactiveSpring(), value: index)
.animation(.interactiveSpring(), value: draggingOffset)
.gesture(
DragGesture()
.updating($draggingOffset) { value, state, _ in
state = value.translation.width
}
.onEnded { value in
let offset = value.translation.width / UIScreen.main.bounds.width
let newIndex = (CGFloat(index) - offset).rounded()
index = min(max(Int(newIndex), 0), urls.count - 1)
}
)
}
}
flashing images(gif)

Related

SwiftUI Dynamic Image Sizing

Problem:I have a View that I needed to place multiple (2) views that contained: 1 Image + 1 Text. I decided to break that up into a ClickableImageAndText structure that I called on twice. This works perfectly if the image is a set size (64x64) but I would like this to work on all size classes. Now, I know that I can do the following:
if horizontalSizeClass == .compact {
Text("Compact")
} else {
Text("Regular")
}
but I am asking for both Different Size Classes and Same Size Classes such as the iPhone X and iPhone 13 which are the same.
Question:How do I alter the image for dynamic phone sizes (iPhone X, 13, 13 pro, etc) so it looks appropriate for all measurements?
Code:
import SwiftUI
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
var body: some View {
VStack {
Image(image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Text(text)
.foregroundColor(.white)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}
struct InitialView: View {
var topView: some View {
Image("Empty_App_Icon")
.resizable()
.scaledToFit()
}
var bottomView: some View {
VStack {
ClickableImageAndText(
image: "Card_Icon",
text: "View Your Memories") {
print("Tapped on View Memories")
}
.padding(.bottom)
ClickableImageAndText(
image: "Camera",
text: "Add Memories") {
print("Tapped on Add Memories")
}
.padding(.top)
}
}
var body: some View {
ZStack {
GradientView()
VStack {
Spacer()
topView
Spacer()
bottomView
Spacer()
}
}
}
}
struct InitialView_Previews: PreviewProvider {
static var previews: some View {
InitialView()
}
}
Image Note:My background includes a GradientView that I have since removed (thanks #lorem ipsum). If you so desire, here is the GradientView code but it is unnecessary for the problem above.
GradientView.swift
import SwiftUI
struct GradientView: View {
let firstColor = Color(uiColor: UIColor(red: 127/255, green: 71/255, blue: 221/255, alpha: 1))
let secondColor = Color(uiColor: UIColor(red: 251/255, green: 174/255, blue: 23/255, alpha: 1))
let startPoint = UnitPoint(x: 0, y: 0)
let endPoint = UnitPoint(x: 0.5, y: 1)
var body: some View {
LinearGradient(gradient:
Gradient(
colors: [firstColor, secondColor]),
startPoint: startPoint,
endPoint: endPoint)
.ignoresSafeArea()
}
}
struct GradientView_Previews: PreviewProvider {
static var previews: some View {
GradientView()
}
}
Effort 1:Added a GeometryReader to my ClickableImageAndText structure and the view is automatically changed incorrectly.
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
var body: some View {
GeometryReader { reader in
VStack {
Image(image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Text(text)
.foregroundColor(.white)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}
}
Effort 2:Added a GeometryReader as directed by #loremipsum's [deleted] answer and the content is still being pushed; specifically, the topView is being push to the top and the bottomView is taking the entire space with the addition of the GeometryReader.
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
var body: some View {
GeometryReader{ geo in
VStack {
Image(image)
.resizable()
.scaledToFit()
//You can do this and set strict size constraints
//.frame(minWidth: 64, maxWidth: 128, minHeight: 64, maxHeight: 128, alignment: .center)
//Or this to set it to be percentage of the size of the screen
.frame(width: geo.size.width * 0.2, alignment: .center)
Text(text)
}.foregroundColor(.white)
//Everything moves to the left because the `View` expecting a size vs stretching.
//If yo want the entire width just set the View with on the outer most View
.frame(width: geo.size.width, alignment: .center)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}
The possible solution is to use screen bounds (which will be different for different phones) as reference value to calculate per-cent-based dynamic size for image. And to track device orientation changes we wrap our calculations into GeometryReader.
Note: I don't have your images, so added white borders for demo purpose
struct ClickableImageAndText: View {
let image: String
let text: String
let tapAction: (() -> Void)
#State private var size = CGFloat(32) // some minimal initial value (not 0)
var body: some View {
VStack {
Image(image)
.resizable()
.scaledToFit()
// .border(Color.white) // << for demo !!
.background(GeometryReader { _ in
// GeometryReader is needed to track orientation changes
let sizeX = UIScreen.main.bounds.width
let sizeY = UIScreen.main.bounds.height
// Screen bounds is needed for reference dimentions, and use
// it to calculate needed size as per-cent to be dynamic
let width = min(sizeX, sizeY)
Color.clear // % (whichever you want)
.preference(key: ViewWidthKey.self, value: width * 0.2)
})
.onPreferenceChange(ViewWidthKey.self) {
self.size = max($0, size)
}
.frame(width: size, height: size)
Text(text)
.foregroundColor(.white)
}
.contentShape(Rectangle())
.onTapGesture {
tapAction()
}
}
}

SwiftUI: Are .slide and .move transition animations broken?

It seems that Transition.slide and Transition.move animations are broken. Using AnyTransition.slide.animation() does not animate. It requires an additional .animation() modifier. This is a problem, since often I want to animate only the slide and nothing else.
Here is a demo. The goal is to have a sliding animation without animating the black ball. The .scale and .opacity animations work just fine. But .slide and .move either do not animate, or they animate the black ball.
import SwiftUI
struct ContentView: View {
#State var screens: [Color] = [Color.red]
var body: some View {
ZStack {
ForEach(screens.indices, id: \.self) { i -> AnyView in
let screen = screens[i]
return AnyView(
Screen(color: screen)
.zIndex(Double(i))
// Try uncommenting these lines one by one. The scale and opacity animations work fine. But move and slide animate only when the additional .animation modifier is uncommented below.
// This is a problem, since uncommenting the standalone .animation modifier, also animates everything inside the Screen() view, which is not what I want.
// .transition(AnyTransition.scale.animation(.linear(duration: 2)))
// .transition(AnyTransition.opacity.animation(.linear(duration: 2)))
// .transition(AnyTransition.move(edge: .leading).animation(.linear(duration: 2)))
.transition(AnyTransition.slide.animation(.linear(duration: 2)))
// .animation(.linear(duration: 2))
)
}
VStack(spacing: 50) {
Text("Screens Count: \(screens.count)")
Button("prev") {
removeScreen()
}
.padding()
.background(Color.white)
Button("next") {
addScreen()
}
.padding()
.background(Color.white)
}
.zIndex(1000)
}
}
func addScreen() {
let colors = [Color.red, Color.blue, Color.green, Color.yellow]
screens.append(colors[screens.count % colors.count])
}
func removeScreen() {
guard screens.count > 1 else { return }
screens.removeLast()
}
}
struct Screen: View {
static var width = UIScreen.main.bounds.width / 2 - 25
static var height = UIScreen.main.bounds.height / 2 - 25
var color: Color
#State var offset = CGSize(width: Self.width, height: -1 * Self.height)
var body: some View {
ZStack {
color
.frame(maxWidth: .infinity, maxHeight: .infinity)
Circle()
.frame(width: 50)
.offset(offset)
}
.onAppear { offset = CGSize(width: Self.width, height: Self.height) }
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Any thoughts how to use slide and move animations without animating everything else inside the view?
Ok, I have a solution. It's not perfect, but it's workable. Putting a view inside a NavigationView somehow separates .animation modifiers:
struct Screen: View {
static var width = UIScreen.main.bounds.width / 2 - 25
static var height = UIScreen.main.bounds.height / 2 - 25
var color: Color
#State var offset = CGSize(width: Self.width, height: -1 * Self.height)
var body: some View {
NavigationView {
ZStack {
color
.frame(maxWidth: .infinity, maxHeight: .infinity)
Circle()
.frame(width: 50)
.offset(offset)
}
.onAppear { offset = CGSize(width: Self.width, height: Self.height) }
.animation(.none)
.navigationBarHidden(true)
}
}
}

How to disable hit test on views that are outside the frame due to offset in SwiftUI?

I am using a Paged Stack that scrolls horizontally and snaps to the selected index. The functionality works fine unless I have it alongside another view. When I scroll, the offset is blocking the view.
I have omitted .clipped() just so it can be visualized. If I add it, it looks visually fine but it is still interact-able, blocking gestures intended for the view below it.
struct PagedStack<Content: View>: View {
let pageCount: Int
let spacing: CGFloat
#Binding var currentIndex: Int
let content: Content
#GestureState private var translation: CGFloat = 0
init(pageCount: Int, spacing: CGFloat, currentIndex: Binding<Int>, #ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self.spacing = spacing
self._currentIndex = currentIndex
self.content = content()
}
var body: some View {
GeometryReader { geometry in
HStack(spacing: self.spacing) {
self.content.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * (geometry.size.width + self.spacing))
.offset(x: self.translation)
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
Here is the visual:
I fixed it. I added .contentShape(Rectangle()) to make the hit test area limited to the frame.

SwuftUI Gesture location detect clicks out of View border

If I put several Views in a row with no spaces (or very little space between them), and attach some Gesture action, I can't correctly detect, which one is tapped. It looks like there is some padding inside gesture that I can't remove.
here's the example code:
struct ContentView: View {
#State var tap = 0
#State var lastClick = CGPoint.zero
var body: some View {
VStack{
Text("last tap: \(tap)")
Text("coordinates: (x: \(Int(lastClick.x)), y: \(Int(lastClick.y)))")
HStack(spacing: 0){
ForEach(0...4, id: \.self){ind in
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.gray)
.overlay(Text("\(ind)"))
.overlay(Circle()
.frame(width: 4, height: 4)
.foregroundColor(self.tap == ind ? Color.red : Color.clear)
.position(self.lastClick)
)
.frame(width: 40, height: 50)
//.border(Color.black, width: 0.5)
.gesture(DragGesture(minimumDistance: 0)
.onEnded(){value in
self.tap = ind
self.lastClick = value.startLocation
}
)
}
}
}
}
}
and behavior:
I want Gesture to detect 0 button clicked when click location goes negative. Is there a way to do that?
I spent few hours with that problem, and just after I post this question, I found solution.
it was so simple - just to add a contentShape modifier
...
.frame(width: 40, height: 50)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0)
...

SwiftUI onTapGesture on Color.clear background behaves differently to Color.blue

I am making a custom Picker in the SegmentedPickerStyle(). I want to have the same behaviour but when I tap on the area between the content and the border of one of the possible selections the onTapGesture does not work. When I add a blue background it does work but with a clear background it doesn't.
Working with blue background
Not working with clear background
Not working code:
import SwiftUI
struct PickerElementView<Content>: View where Content : View {
#Binding var selectedElement: Int
let content: () -> Content
#inlinable init(_ selectedElement: Binding<Int>, #ViewBuilder content: #escaping () -> Content) {
self._selectedElement = selectedElement
self.content = content
}
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.background(Color.clear)
// ##################################################################
.border(Color.yellow, width: 5)
}
}
}
struct PickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
var elements: [(id: Int, view: AnyView)]
#Binding var selectedElement: Int
#State var internalSelectedElement: Int = 0
private var width: CGFloat = 220
private var height: CGFloat = 100
private var cornerRadius: CGFloat = 20
private var factor: CGFloat = 0.95
private var color = Color(UIColor.systemGray)
private var selectedColor = Color(UIColor.systemGray2)
init(_ selectedElement: Binding<Int>) {
self._selectedElement = selectedElement
self.elements = [
(id: 0, view: AnyView(PickerElementView(selectedElement) {
Text("9").font(.system(.title))
})),
(id: 1, view: AnyView(PickerElementView(selectedElement) {
Text("5").font(.system(.title))
})),
]
self.internalSelectedElement = selectedElement.wrappedValue
}
func calcXPosition() -> CGFloat {
var pos = CGFloat(-self.width * self.factor / 4)
pos += CGFloat(self.internalSelectedElement) * self.width * self.factor / 2
return pos
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(self.selectedColor)
.cornerRadius(self.cornerRadius * self.factor)
.frame(width: self.width * self.factor / CGFloat(self.elements.count), height: self.height - self.width * (1 - self.factor))
.offset(x: calcXPosition())
.animation(.easeInOut(duration: 0.2))
HStack {
ForEach(self.elements, id: \.id) { item in
item.view
.gesture(TapGesture().onEnded { _ in
print(item.id)
self.selectedElement = item.id
withAnimation {
self.internalSelectedElement = item.id
}
})
}
}
}
.frame(width: self.width, height: self.height)
.background(self.color)
.cornerRadius(self.cornerRadius)
.padding()
}
}
struct PickerView_Previews: PreviewProvider {
static var previews: some View {
PickerView(.constant(1))
}
}
Change the color where I marked it.
Does anyone know why they behave differently and how I can fix this?
The one line answer is instead of setting backgroundColor, please set contentShape for hit testing.
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.contentShape(Rectangle())
// ##################################################################
.border(Color.yellow, width: 5)
}
}
Transparent views are not tappable by default in SwiftUI because their content shape is zero.
You can change this behavior by using .contentShape modifier:
Color.clear
.frame(width: 300, height: 300)
.contentShape(Rectangle())
.onTapGesture { print("tapped") }
It appears to be a design decision that any Color with an opacity of 0 is untappable.
Color.clear.onTapGesture { print("tapped") } // will not print
Color.blue.opacity(0).onTapGesture { print("tapped") } // will not print
Color.blue.onTapGesture { print("tapped") } // will print
Color.blue.opacity(0.0001).onTapGesture { print("tapped") } // will print
You can use the 4th option to get around this, as it is visually indistinguishable from the 1st.
I was struggling a similar problem to get the tap on a RoundedRectangle.
My simple solution was to set the opacity to a very low value and it worked
RoundedRectangle(cornerRadius: 12)
.fill(Color.black)
.opacity(0.0001)
.frame(width: 32, height: 32)
.onTapGesture {
...
}