How to define drag gesture in property on SwiftUI view - swiftui

I cannot return DragGesture from computed property like below
'_EndedGesture<_ChangedGesture<DragGesture>>' to return type 'DragGesture'
var dimDrag : DragGesture {
DragGesture()
.onChanged({
print("Dim drag")
if $0.translation.width > 0 {
self.model.menuOffset = max(min($0.translation.width, UIScreen.main.bounds.width*0.7), 0.0)
} else {
self.model.menuOffset = max(min(UIScreen.main.bounds.width*0.7 + $0.translation.width, UIScreen.main.bounds.width*0.7), 0.0)
}
})
.onEnded({
if $0.translation.width < -100 {
withAnimation {
self.model.isMenuOpen = true
self.model.menuOffset = 0.0
}
} else if $0.translation.width > 100 {
withAnimation {
self.model.isMenuOpen = false
self.model.menuOffset = UIScreen.main.bounds.width*0.7
}
}
})
}

The following works for me:
let myDrag = {
DragGesture()
.onChanged({ value in
})
.onEnded { value in
}
}()

Related

TabView disconnects when rotating to Landscape due to SwiftUI's re-render of parent-Views

Using Swift5.3.2, iOS14.4.1, XCode12.4,
As the following code shows, I am working with a quite complex TabView in Page-Mode in SwiftUI.
i.e. using iOS14's new possibility to show Pages:
.tabViewStyle(PageTabViewStyle())
Everything works.
Except, if I rotate my iPhone from Portrait to Landscape, the TabView disconnects and sets the selectedTab index to 0 (i.e. no matter where you scrolled to, rotating iPhone resets unwontedly to page 0).
The parent-View itself is in a complex View hierarchy. And one of the parent-View's of the TabView is updated during the TabView is shown (and swiped). And this might be the problem that the TabView gets re-rendered when rotating to Landscape.
What can I do to keep the TabView-Page during iPhone rotation ??
Here is the code:
import SwiftUI
struct PageViewiOS: View {
var body: some View {
ZStack {
Color.black
MediaTabView()
CloseButtonView()
}
}
}
And the MediaTabView at question:
struct MediaTabView: View {
#EnvironmentObject var appStateService: AppStateService
#EnvironmentObject var commService: CommunicationService
#State private var tagID = ""
#State private var selectedTab = 0
#State private var uniqueSelected = 0
#State private var IamInSwipingAction = false
var body: some View {
let myDragGesture = DragGesture(minimumDistance: 10)
.onChanged { _ in
IamInSwipingAction = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(5000)) {
IamInSwipingAction = false // workaround: because onEnded does not work...
}
}
.onEnded { _ in
IamInSwipingAction = false
}
TabView(selection: self.$selectedTab) {
if let list = appStateService.mediaViewModel.mediaList.first(where: { (list) -> Bool in
switch appStateService.appState {
case .content(let tagID):
return list.tagId == tagID
default:
return false
}
}) {
if list.paths.count > 0 {
ForEach(list.paths.indices, id: \.self) { index in
ZoomableScrollView {
if let url = URL(fileURLWithPath: list.paths[index]){
if url.containsImage {
Image(uiImage: UIImage(contentsOfFile: url.path)!)
.resizable()
.scaledToFit()
} else if url.containsVideo {
CustomPlayerView(url: url)
} else {
Text(LocalizedStringKey("MediaNotRecognizedKey"))
.multilineTextAlignment(.center)
.padding()
}
} else {
Text(LocalizedStringKey("MediaNotRecognizedKey"))
.multilineTextAlignment(.center)
.padding()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.onAppear() {
if uniqueSelected != selectedTab {
uniqueSelected = selectedTab
if IamInSwipingAction && (commService.communicationRole == .moderatorMode) {
commService.send(thCmd: THCmd(key: .swipeID, sender: "", content: URL(fileURLWithPath: list.paths[index]).lastPathComponent))
}
}
}
}
} else {
Text(LocalizedStringKey("EmptyOrNoTrihowAlbumKey"))
.multilineTextAlignment(.center)
.padding()
}
} else {
if Constants.TrihowAlbum.tagIdArrayTrihowAlbum.contains(tagID) {
Text(LocalizedStringKey("EmptyOrNoTrihowAlbumKey"))
.multilineTextAlignment(.center)
.padding()
} else {
Text(LocalizedStringKey("TagNotRecognizedKey"))
.multilineTextAlignment(.center)
.padding()
}
}
}
.onAppear() {
switch appStateService.appState {
case .content(let tagID):
self.tagID = tagID
default:
self.tagID = ""
}
}
.tabViewStyle(PageTabViewStyle())
.onTHComm_ReceiveCmd(service: commService) { (thCmd) in
switch thCmd.key {
case .swipeID:
if (commService.communicationRole == .moderatorMode) || (commService.communicationRole == .discoveryMode) {
selectTabFromCmdID(fileName: thCmd.content)
} else {
break
}
default:
break
}
}
.simultaneousGesture(myDragGesture)
}
}
extension MediaTabView {
private func selectTabFromCmdID(fileName: String) {
if let list = appStateService.mediaViewModel.mediaList.first(where: { (list) -> Bool in
return list.tagId == tagID
}) {
if list.paths.count > 0 {
if let idx = list.paths.firstIndex(where: { (urlPath) -> Bool in
if let url = URL(string: urlPath) {
return url.lastPathComponent == fileName
} else { return false }
}) {
selectedTab = idx
}
}
}
}
}

How to use matchedGeometryEffect and view scaling

I'm trying to make animation when I tap on view it becomes full screen and when I drag down view it scales down and returns to its previous state. I use matchedGeometryEffect with two views and change destination view frame with DragGesture, transition to source view works «unexpectedly». How to fix it or how to make this animation correct? If I don't change destination view frame it works as I expect (click button). GIF here: https://i.stack.imgur.com/yXsjF.gif
struct Test4: View {
#Namespace var animation
#State private var show = false
#State private var scale: CGFloat = 1
var body: some View {
ZStack(alignment: .topTrailing) {
if show {
ScrollView {
Color.gray
.border(Color.black, width: 30)
.matchedGeometryEffect(id: "animation", in: animation)
.frame(
width: UIScreen.main.bounds.width * scale,
height: UIScreen.main.bounds.height * scale)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged(onChanged)
.onEnded(onEnded)
)
}
.ignoresSafeArea()
Button("go back") {
withAnimation(Animation.easeInOut(duration: 1)) {
self.show.toggle()
}
}
} else {
VStack {
Color.gray
.border(Color.black, width: 30)
.matchedGeometryEffect(id: "animation", in: animation)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation {
self.scale = 1
show.toggle()
}
}
}
}
}
}
func onChanged(value: DragGesture.Value) {
withAnimation(Animation.easeInOut(duration: 3)) {
let currentScale = value.translation.height / UIScreen.main.bounds.height
if currentScale <= 0 {
return
}
let newScale = 1 - currentScale
if newScale > 0.85 {
self.scale = newScale
} else if newScale < 0.85 {
self.show = false
}
}
}
func onEnded(value: DragGesture.Value) {
withAnimation(Animation.easeInOut(duration: 3)) {
if self.scale < 0.85 {
self.show = false
}
}
}
}
struct Test4_Previews: PreviewProvider {
static var previews: some View {
Test4()
}
}
The thing that you are trying get is possible without matchedGeometryEffect, here is a simple approach:
import SwiftUI
struct ContentView: View {
var body: some View {
ScaleView()
}
}
struct ScaleView: View {
#State private var translation: CGFloat = CGFloat()
#State private var lastTranslation: CGFloat = CGFloat()
var body: some View {
GeometryReader { geometry in
Color.black
ZStack {
Color
.gray
Image(systemName: "arrow.triangle.2.circlepath.circle")
.font(Font.largeTitle)
.onTapGesture {
if lastTranslation == 400.0 {
lastTranslation = 0.0
translation = lastTranslation
}
else {
lastTranslation = 400.0
translation = lastTranslation
}
}
}
.position(x: geometry.size.width/2, y: geometry.size.height/2)
.cornerRadius(30)
.scaleEffect(1.0 - translation/(geometry.size.width > geometry.size.height ? geometry.size.width : geometry.size.height))
.gesture(DragGesture(minimumDistance: 0.0).onChanged(onChanged).onEnded(onEnded))
}
.ignoresSafeArea()
.animation(.easeInOut(duration: 0.35))
.statusBar(hidden: true)
}
func onChanged(value: DragGesture.Value) { translation = lastTranslation + value.translation.height }
func onEnded(value: DragGesture.Value) {
if value.translation.height > 0.0 {
lastTranslation = 400.0
translation = lastTranslation
}
else {
lastTranslation = 0.0
translation = lastTranslation
}
}
}

How to drag and drop images not in bundle in SwiftUI (macOS)

I am trying to implement drag and drop in SwiftUI on macOS where I can either generate images programmatically and have them appear in the view as draggable items or load images from Assets.xcassets for the same purpose. I am basing my code off of this. I tried referring to this question but I couldn't get it to work.
I got the image displaying fine, but since I am referencing the image itself, there is no URL I can return for the drag and drop API (see below):
//Value of type 'ContentView.DragableImage' has no member 'url' so I cannot use this
.onDrag { return NSItemProvider(object: self.url as NSURL) }
Here is the code. I have pointed things out via comments in the code:
import SwiftUI
let img1url = Bundle.main.url(forResource: "grapes", withExtension: "png") // < -- CAN pass this in because it is by url
let img2url = Bundle.main.url(forResource: "banana", withExtension: "png")
let img3url = Bundle.main.url(forResource: "peach", withExtension: "png")
let img4url = Bundle.main.url(forResource: "kiwi", withExtension: "png")
struct ContentView: View {
var body: some View {
HStack {
VStack {
//DragableImage(url: img1url!)
//DragableImage(url: img3url!)
DragableImage()
DragableImage()
}
VStack {
//DragableImage(url: img2url!)
// DragableImage(url: img4url!)
DragableImage()
DragableImage()
}
DroppableArea()
}.padding(40)
}
struct DragableImage: View {
//let url: URL
var body: some View {
Image("grapes") //<--- Takes in image without url fine
//Image(nsImage: NSImage(byReferencing: url)) //<--- Taking in image by URL (I don't want that)
.resizable()
.frame(width: 150, height: 150)
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 2))
.padding(2)
.overlay(Circle().strokeBorder(Color.black.opacity(0.1)))
.shadow(radius: 3)
.padding(4)
.onDrag { return NSItemProvider(object: self.url as NSURL) } //<--- MAIN ISSUE: "Value of type 'ContentView.DragableImage' has no member 'url'" (there is now no URL to reference the image by in the return)
}
}
struct DroppableArea: View {
#State private var imageUrls: [Int: URL] = [:]
#State private var active = 0
var body: some View {
let dropDelegate = MyDropDelegate(imageUrls: $imageUrls, active: $active)
return VStack {
HStack {
GridCell(active: self.active == 1, url: imageUrls[1])
GridCell(active: self.active == 3, url: imageUrls[3])
}
HStack {
GridCell(active: self.active == 2, url: imageUrls[2])
GridCell(active: self.active == 4, url: imageUrls[4])
}
}
.background(Rectangle().fill(Color.gray))
.frame(width: 300, height: 300)
.onDrop(of: ["public.file-url"], delegate: dropDelegate)
}
}
struct GridCell: View {
let active: Bool
let url: URL?
var body: some View {
let img = Image(nsImage: url != nil ? NSImage(byReferencing: url!) : NSImage())
.resizable()
.frame(width: 150, height: 150)
return Rectangle()
.fill(self.active ? Color.green : Color.clear)
.frame(width: 150, height: 150)
.overlay(img)
}
}
struct MyDropDelegate: DropDelegate {
#Binding var imageUrls: [Int: URL]
#Binding var active: Int
func validateDrop(info: DropInfo) -> Bool {
return info.hasItemsConforming(to: ["public.file-url"])
}
func dropEntered(info: DropInfo) {
NSSound(named: "Morse")?.play()
}
func performDrop(info: DropInfo) -> Bool {
NSSound(named: "Submarine")?.play()
let gridPosition = getGridPosition(location: info.location)
self.active = gridPosition
if let item = info.itemProviders(for: ["public.file-url"]).first {
item.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (urlData, error) in
DispatchQueue.main.async {
if let urlData = urlData as? Data {
self.imageUrls[gridPosition] = NSURL(absoluteURLWithDataRepresentation: urlData, relativeTo: nil) as URL
}
}
}
return true
} else {
return false
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
self.active = getGridPosition(location: info.location)
return nil
}
func dropExited(info: DropInfo) {
self.active = 0
}
func getGridPosition(location: CGPoint) -> Int {
if location.x > 150 && location.y > 150 {
return 4
} else if location.x > 150 && location.y < 150 {
return 3
} else if location.x < 150 && location.y > 150 {
return 2
} else if location.x < 150 && location.y < 150 {
return 1
} else {
return 0
}
}
}
}
I have also tried the following with far more success. It highlights upon image drag but on drop it will not show up:
import SwiftUI
let image1 = NSImage(named: "green")!
let image2 = NSImage(named: "blue")!
struct ContentView: View {
var body: some View {
HStack {
VStack {
DragableImage(image: image1)
DragableImage(image: image2)
}
VStack {
DragableImage(image: image1)
DragableImage(image: image2)
}
DroppableArea()
}.padding(40)
}
struct DragableImage: View {
#State var image: NSImage
#State private var dragOver = false
var body: some View {
Image(nsImage: image)
.onDrop(of: ["public.file-url"], isTargeted: $dragOver) { providers -> Bool in
providers.first?.loadDataRepresentation(forTypeIdentifier: "public.file-url", completionHandler: { (data, error) in
if let data = data, let path = NSString(data: data, encoding: 4), let url = URL(string: path as String) {
let imageLocal = NSImage(contentsOf: url)
DispatchQueue.main.async {
self.image = imageLocal!
}
}
})
return true
}
.onDrag {
let data = self.image.tiffRepresentation
let provider = NSItemProvider(item: data as NSSecureCoding?, typeIdentifier: kUTTypeTIFF as String)
provider.previewImageHandler = { (handler, _, _) -> Void in
handler?(data as NSSecureCoding?, nil)
}
return provider
}
.border(dragOver ? Color.red : Color.clear)
}
}
struct DroppableArea: View {
#State private var imageUrls: [Int: URL] = [:]
#State private var active = 0
var body: some View {
let dropDelegate = MyDropDelegate(imageUrls: $imageUrls, active: $active)
return VStack {
HStack {
GridCell(active: self.active == 1, url: imageUrls[1])
GridCell(active: self.active == 3, url: imageUrls[3])
}
HStack {
GridCell(active: self.active == 2, url: imageUrls[2])
GridCell(active: self.active == 4, url: imageUrls[4])
}
}
.background(Rectangle().fill(Color.gray))
.frame(width: 300, height: 300)
.onDrop(of: ["public.file-url"], delegate: dropDelegate)
}
}
struct GridCell: View {
let active: Bool
let url: URL?
var body: some View {
let img = Image(nsImage: url != nil ? NSImage(byReferencing: url!) : NSImage())
.resizable()
.frame(width: 150, height: 150)
return Rectangle()
.fill(self.active ? Color.green : Color.clear)
.frame(width: 150, height: 150)
.overlay(img)
}
}
struct MyDropDelegate: DropDelegate {
#Binding var imageUrls: [Int: URL]
#Binding var active: Int
func validateDrop(info: DropInfo) -> Bool {
return info.hasItemsConforming(to: ["public.file-url"])
}
func dropEntered(info: DropInfo) {
NSSound(named: "Morse")?.play()
}
func performDrop(info: DropInfo) -> Bool {
NSSound(named: "Submarine")?.play()
let gridPosition = getGridPosition(location: info.location)
self.active = gridPosition
if let item = info.itemProviders(for: ["public.file-url"]).first {
item.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (urlData, error) in
DispatchQueue.main.async {
if let urlData = urlData as? Data {
self.imageUrls[gridPosition] = NSURL(absoluteURLWithDataRepresentation: urlData, relativeTo: nil) as URL
}
}
}
return true
} else {
return false
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
self.active = getGridPosition(location: info.location)
return nil
}
func dropExited(info: DropInfo) {
self.active = 0
}
func getGridPosition(location: CGPoint) -> Int {
if location.x > 150 && location.y > 150 {
return 4
} else if location.x > 150 && location.y < 150 {
return 3
} else if location.x < 150 && location.y > 150 {
return 2
} else if location.x < 150 && location.y < 150 {
return 1
} else {
return 0
}
}
}
}
It is almost the same as in references questions, the key is to use NSImage as model, instead of Image directly, to have ability to have access to image data to work with NSItemProvider:
struct DragableImage: View {
var image = NSImage(named: "grapes")
var body: some View {
Image(nsImage: image ?? NSImage())
.resizable()
.frame(width: 150, height: 150)
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 2))
.padding(2)
.overlay(Circle().strokeBorder(Color.black.opacity(0.1)))
.shadow(radius: 3)
.padding(4)
.onDrag {
let data = self.image?.tiffRepresentation
let provider = NSItemProvider(item: data as NSSecureCoding?, typeIdentifier: kUTTypeTIFF as String)
provider.previewImageHandler = { (handler, _, _) -> Void in
handler?(data as NSSecureCoding?, nil)
}
return provider
}
}
}

How to detect Swiping UP, DOWN, LEFT and RIGHT with SwiftUI on a View

I'm getting into building Apple Watch apps.
What I'm currently working on will require me to make use of detecting swipes in the four main directions (UP, DOWN, LEFT and RIGHT)
The problem is I have no idea how to detect this. I've been looking around and I'm reaching dead ends.
What can I do to my view below to just print swiped up when the user swipes UP on the view?
struct MyView: View {
var body: some View {
Text("Hello, World!")
}
}
Thanks.
You could use DragGesture
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded({ value in
if value.translation.width < 0 {
// left
}
if value.translation.width > 0 {
// right
}
if value.translation.height < 0 {
// up
}
if value.translation.height > 0 {
// down
}
}))
With the other solutions being a bit inconsistent on a physical device, I decided to come up with another one that seems to be much more consistent across different screen sizes as there are no hardcoded values except for the minimumDistance.
.gesture(DragGesture(minimumDistance: 20, coordinateSpace: .global)
.onEnded { value in
let horizontalAmount = value.translation.width
let verticalAmount = value.translation.height
if abs(horizontalAmount) > abs(verticalAmount) {
print(horizontalAmount < 0 ? "left swipe" : "right swipe")
} else {
print(verticalAmount < 0 ? "up swipe" : "down swipe")
}
})
If you want one that is more "forgiving" to the directionality of the swipe, you can use a few more conditionals to help even it out:
EDIT: did some more testing, apparently the values for the second conditional add some confusion, so I adjusted them to remove said confusion and make the gesture bulletproof (drags to the corners will now come up with "no clue" instead of one of the gestures)...
let detectDirectionalDrags = DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
print(value.translation)
if value.translation.width < 0 && value.translation.height > -30 && value.translation.height < 30 {
print("left swipe")
}
else if value.translation.width > 0 && value.translation.height > -30 && value.translation.height < 30 {
print("right swipe")
}
else if value.translation.height < 0 && value.translation.width < 100 && value.translation.width > -100 {
print("up swipe")
}
else if value.translation.height > 0 && value.translation.width < 100 && value.translation.width > -100 {
print("down swipe")
}
else {
print("no clue")
}
Based on Benjamin's answer this is a swiftier way to handle the cases
.gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
print(value.translation)
switch(value.translation.width, value.translation.height) {
case (...0, -30...30): print("left swipe")
case (0..., -30...30): print("right swipe")
case (-100...100, ...0): print("up swipe")
case (-100...100, 0...): print("down swipe")
default: print("no clue")
}
}
)
Create an extension:
extension View {
func swipe(
up: #escaping (() -> Void) = {},
down: #escaping (() -> Void) = {},
left: #escaping (() -> Void) = {},
right: #escaping (() -> Void) = {}
) -> some View {
return self.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded({ value in
if value.translation.width < 0 { left() }
if value.translation.width > 0 { right() }
if value.translation.height < 0 { up() }
if value.translation.height > 0 { down() }
}))
}
}
Then:
Image() // or any View
.swipe(
up: {
// action for up
},
right: {
// action for right
})
Notice that each direction is an optional parameter
I would create a modifier for simplicity. Usage will look like that:
yourView
.onSwiped(.down) {
// Action for down swipe
}
OR
yourView
.onSwiped { direction in
// React to detected swipe direction
}
You can also use trigger parameter in order to configure receiving updates: continuously or only when the gesture ends.
Here's the full code:
struct SwipeModifier: ViewModifier {
enum Directions: Int {
case up, down, left, right
}
enum Trigger {
case onChanged, onEnded
}
var trigger: Trigger
var handler: ((Directions) -> Void)?
func body(content: Content) -> some View {
content.gesture(
DragGesture(
minimumDistance: 24,
coordinateSpace: .local
)
.onChanged {
if trigger == .onChanged {
handle($0)
}
}.onEnded {
if trigger == .onEnded {
handle($0)
}
}
)
}
private func handle(_ value: _ChangedGesture<DragGesture>.Value) {
let hDelta = value.translation.width
let vDelta = value.translation.height
if abs(hDelta) > abs(vDelta) {
handler?(hDelta < 0 ? .left : .right)
} else {
handler?(vDelta < 0 ? .up : .down)
}
}
}
extension View {
func onSwiped(
trigger: SwipeModifier.Trigger = .onChanged,
action: #escaping (SwipeModifier.Directions) -> Void
) -> some View {
let swipeModifier = SwipeModifier(trigger: trigger) {
action($0)
}
return self.modifier(swipeModifier)
}
func onSwiped(
_ direction: SwipeModifier.Directions,
trigger: SwipeModifier.Trigger = .onChanged,
action: #escaping () -> Void
) -> some View {
let swipeModifier = SwipeModifier(trigger: trigger) {
if direction == $0 {
action()
}
}
return self.modifier(swipeModifier)
}
}
This is much more responsive:
.gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
let direction = atan2(value.translation.width, value.translation.height)
switch direction {
case (-Double.pi/4..<Double.pi/4): self.playMove(.down)
case (Double.pi/4..<Double.pi*3/4): self.playMove(.right)
case (Double.pi*3/4...Double.pi), (-Double.pi..<(-Double.pi*3/4)):
self.playMove(.up)
case (-Double.pi*3/4..<(-Double.pi/4)): self.playMove(.left)
default:
print("unknown)")
}
}
Little bit late to this, but here's another implementation which uses OptionSet to make its use a bit more like various other SwiftUI components -
struct Swipe: OptionSet, Equatable {
init(rawValue: Int) {
self.rawValue = rawValue
}
let rawValue: Int
fileprivate var swiped: ((DragGesture.Value, Double) -> Bool) = { _, _ in false } // prevents a crash if someone creates a swipe directly using the init
private static let sensitivityFactor: Double = 400 // a fairly arbitrary figure which gives a reasonable response
static var left: Swipe {
var swipe = Swipe(rawValue: 1 << 0)
swipe.swiped = { value, sensitivity in
value.translation.width < 0 && value.predictedEndTranslation.width < sensitivity * sensitivityFactor
}
return swipe
}
static var right: Swipe {
var swipe = Swipe(rawValue: 1 << 1)
swipe.swiped = { value, sensitivity in
value.translation.width > 0 && value.predictedEndTranslation.width > sensitivity * sensitivityFactor
}
return swipe
}
static var up: Swipe {
var swipe = Swipe(rawValue: 1 << 2)
swipe.swiped = { value, sensitivity in
value.translation.height < 0 && value.predictedEndTranslation.height < sensitivity * sensitivityFactor
}
return swipe
}
static var down: Swipe {
var swipe = Swipe(rawValue: 1 << 3)
swipe.swiped = { value, sensitivity in
value.translation.height > 0 && value.predictedEndTranslation.height > sensitivity * sensitivityFactor
}
return swipe
}
static var all: Swipe {
[.left, .right, .up, .down]
}
private static var allCases: [Swipe] = [.left, .right, .up, .down]
fileprivate var array: [Swipe] {
Swipe.allCases.filter { self.contains($0) }
}
}
extension View {
func swipe(_ swipe: Swipe, sensitivity: Double = 1, action: #escaping (Swipe) -> ()) -> some View {
return gesture(DragGesture(minimumDistance: 30, coordinateSpace: .local)
.onEnded { value in
swipe.array.forEach { swipe in
if swipe.swiped(value, sensitivity) {
action(swipe)
}
}
}
)
}
}
In a SwiftUI view -
HStack {
// content
}
.swipe([.left, .right]) { swipe in // could also be swipe(.left) or swipe(.all), etc
doSomething(with: swipe)
}
Obviously the logic for detecting swipes is a bit basic, but that's easy enough to tailor to your requirements.

SWIFTUI Marquee when text not fit

I have text but it's not fit. I want use marquee when text not fit in my default frame.
Text(self.viewModel.soundTrack.title)
.font(.custom("Avenir Next Regular", size: 24))
.multilineTextAlignment(.trailing)
.lineLimit(1)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
//.frame(width: 200.0, height: 30.0)
Try below code....
In MarqueeText.swift
import SwiftUI
struct MarqueeText: View {
#State private var leftMost = false
#State private var w: CGFloat = 0
#State private var previousText: String = ""
#State private var contentViewWidth: CGFloat = 0
#State private var animationDuration: Double = 5
#Binding var text : String
var body: some View {
let baseAnimation = Animation.linear(duration: self.animationDuration)//Animation duration
let repeated = baseAnimation.repeatForever(autoreverses: false)
return VStack(alignment:.center, spacing: 0) {
GeometryReader { geometry in//geometry.size.width will provide container/superView width
Text(self.text).font(.system(size: 24)).lineLimit(1).foregroundColor(.clear).fixedSize(horizontal: true, vertical: false).background(TextGeometry()).onPreferenceChange(WidthPreferenceKey.self, perform: {
self.w = $0
print("textWidth:\(self.w)")
print("geometry:\(geometry.size.width)")
self.contentViewWidth = geometry.size.width
if self.text.count != self.previousText.count && self.contentViewWidth < self.w {
let duration = self.w/50
print("duration:\(duration)")
self.animationDuration = Double(duration)
self.leftMost = true
} else {
self.animationDuration = 0.0
}
self.previousText = self.text
}).fixedSize(horizontal: false, vertical: true)// This Text is temp, will not be displayed in UI. Used to identify the width of the text.
if self.animationDuration > 0.0 {
Text(self.text).font(.system(size: 24)).lineLimit(nil).foregroundColor(.green).fixedSize(horizontal: true, vertical: false).background(TextGeometry()).onPreferenceChange(WidthPreferenceKey.self, perform: { _ in
if self.text.count != self.previousText.count && self.contentViewWidth < self.w {
} else {
self.leftMost = false
}
self.previousText = self.text
}).modifier(self.makeSlidingEffect().ignoredByLayout()).animation(repeated, value: self.leftMost).clipped(antialiased: true).offset(y: -8)//Text with animation
}
else {
Text(self.text).font(.system(size: 24)).lineLimit(1).foregroundColor(.blue).fixedSize(horizontal: true, vertical: false).background(TextGeometry()).fixedSize(horizontal: false, vertical: true).frame(maxWidth: .infinity, alignment: .center).offset(y: -8)//Text without animation
}
}
}.fixedSize(horizontal: false, vertical: true).layoutPriority(1).frame(maxHeight: 50, alignment: .center).clipped()
}
func makeSlidingEffect() -> some GeometryEffect {
return SlidingEffect(
xPosition: self.leftMost ? -self.w : self.w,
yPosition: 0).ignoredByLayout()
}
}
struct MarqueeText_Previews: PreviewProvider {
#State static var myCoolText = "myCoolText"
static var previews: some View {
MarqueeText(text: $myCoolText)
}
}
struct SlidingEffect: GeometryEffect {
var xPosition: CGFloat = 0
var yPosition: CGFloat = 0
var animatableData: CGFloat {
get { return xPosition }
set { xPosition = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let pt = CGPoint(
x: xPosition,
y: yPosition)
return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y)).inverted()
}
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct MagicStuff: ViewModifier {
func body(content: Content) -> some View {
Group {
content.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}
}
}
}
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
In your existing SwiftUI struct.
(The below sample code will check 3 cases 1.Empty string, 2.Short string that doesn't need to marquee, 3.Lengthy marquee string)
#State var value = ""
#State var counter = 0
var body: some View {
VStack {
Spacer(minLength: 0)
Text("Monday").background(Color.yellow)
HStack {
Spacer()
VStack {
Text("One").background(Color.blue)
}
VStack {
MarqueeText(text: $value).background(Color.red).padding(.horizontal, 8).clipped()
}
VStack {
Text("Two").background(Color.green)
}
Spacer()
}
Text("Tuesday").background(Color.gray)
Spacer(minLength: 0)
Button(action: {
self.counter = self.counter + 1
if (self.counter % 2 == 0) {
self.value = "1Hello World! Hello World! Hello World! Hello World! Hello World!"
} else {
self.value = "1Hello World! Hello"
}
}) {
Text("Button")
}
Spacer()
}
}
Install https://github.com/SwiftUIKit/Marquee 0.2.0 above
with Swift Package Manager and try below code....
struct ContentView: View {
var body: some View {
Marquee {
Text("Hello World!")
.font(.system(size: 40))
}
// This is the key point.
.marqueeWhenNotFit(true)
}
}
When you keep increasing the length of the text until it exceeds the width of the marquee, the marquee animation will automatically start.
I was looking for the same thing, but every solution I tried either did not meet my specifications or caused layout/rendering issues, especially when the text changed or the parent view was refreshed. I ended up just writing something from scratch. It is quite hack-y, but it seems to be working now. I would welcome any suggestions on how it can be improved!
import SwiftUI
struct Marquee: View {
#ObservedObject var controller:MarqueeController
var body: some View {
VStack {
if controller.changing {
Text("")
.font(Font(controller.font))
} else {
if !controller.shouldAnimate {
Text(controller.text)
.font(Font(controller.font))
} else {
AnimatedText(controller: controller)
}
}
}
.onAppear() {
self.controller.checkForAnimation()
}
.onReceive(controller.$text) {_ in
self.controller.checkForAnimation()
}
}
}
struct AnimatedText: View {
#ObservedObject var controller:MarqueeController
var body: some View {
Text(controller.text)
.font(Font(controller.font))
.lineLimit(1)
.fixedSize()
.offset(x: controller.animate ? controller.initialOffset - controller.offset : controller.initialOffset)
.frame(width:controller.maxWidth)
.mask(Rectangle())
}
}
class MarqueeController:ObservableObject {
#Published var text:String
#Published var animate = false
#Published var changing = true
#Published var offset:CGFloat = 0
#Published var initialOffset:CGFloat = 0
var shouldAnimate:Bool {text.widthOfString(usingFont: font) > maxWidth}
let font:UIFont
var maxWidth:CGFloat
var textDoubled = false
let delay:Double
let duration:Double
init(text:String, maxWidth:CGFloat, font:UIFont = UIFont.systemFont(ofSize: 12), delay:Double = 1, duration:Double = 3) {
self.text = text
self.maxWidth = maxWidth
self.font = font
self.delay = delay
self.duration = duration
}
func checkForAnimation() {
if shouldAnimate {
let spacer = " "
if !textDoubled {
self.text += (spacer + self.text)
self.textDoubled = true
}
let textWidth = self.text.widthOfString(usingFont: font)
self.initialOffset = (textWidth - maxWidth) / 2
self.offset = (textWidth + spacer.widthOfString(usingFont: font)) / 2
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.changing = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(Animation.linear(duration:self.duration).delay(self.delay).repeatForever(autoreverses: false)) {
self.animate = self.shouldAnimate
}
}
}
}
}