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

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
}
}
}

Related

Flash with SwiftUI/AV Foundation Error code -16800

I am trying to use flash when taking an image using AV Foundation in Swift UI. However, when I try to take a picture, I get the following error code.
Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSUnderlyingError=0x28004e790 {Error Domain=NSOSStatusErrorDomain Code=-16800 "(null)"}, NSLocalizedFailureReason=An unknown error occurred (-16800), AVErrorRecordingFailureDomainKey=4, NSLocalizedDescription=The operation could not be completed}
Below is the code that I am using for my camera that is generating this issue. I have gone through and commented some areas that I thought might be the source of the issue as I was trying to figure this out, but I may be wrong.
import SwiftUI
import AVFoundation
struct Camera: View {
var body: some View {
CameraView()
}
}
struct Camera_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
// Test code: Ignore
struct globalVariable {
public var isBack = false
}
class GlobalModel : ObservableObject {
#Published var isBack = false
func get() -> Bool{
return isBack
}
func setTrue() {
isBack = true
}
func setFalse() {
isBack = false
}
}
//
struct CameraView: View { // Creates the camera preview elements
#StateObject var camera = CameraModel()
#State var img : UIImage? = nil
#State var navigated = false
#ObservedObject var nextScreen = GlobalModel()
var body: some View{
ZStack{
CameraPreview(camera: camera)
.ignoresSafeArea(.all, edges: .all)
VStack{
Spacer()
HStack{
if camera.isTaken {
Button(action: {
camera.reTake()
self.nextScreen.setFalse()
print(nextScreen.get())
}, label: {
Text("Retake").foregroundColor(.black)
.fontWeight(.semibold)
.padding(.vertical, 10)
.padding(.horizontal, 30)
.background(Color.white)
.clipShape(Capsule())
}).padding(.trailing)
Spacer()
ZStack{
NavigationLink("", destination: Classify(originalImage: img, label: "", confidence: 0.0), isActive: $navigated)
Button(action:
{if !camera.isLoaded{
img = camera.savePic()
if img != nil{
print("is not nil")
}
self.navigated.toggle()
self.nextScreen.setTrue()
print(nextScreen.get())
}
}, label: {
Text("Continue").foregroundColor(.black)
.fontWeight(.semibold)
.padding(.vertical, 10)
.padding(.horizontal, 30)
.background(Color.white)
.clipShape(Capsule())
}).padding(.leading).opacity(nextScreen.get() ? 0.01 : 1)
}
}
else{
Button(action: camera.takePic, label: {
ZStack{
Image(systemName: "camera.circle")
.frame(width: 70, height: 75).font(.system(size: 60))
}
})
}
}.frame(height: 75)
}
}.onAppear(perform: {
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") // Forcing the rotation to portrait
AppDelegate.orientationLock = .portrait // And making sure it stays that way
//UITabBar.appearance().isHidden = true
camera.Check()
})
.onDisappear(){
AppDelegate.orientationLock = .all
UITabBar.appearance().isHidden = false
}
}
}
class CameraModel: NSObject, ObservableObject, AVCapturePhotoCaptureDelegate {
#Published var isTaken = false
#Published var session = AVCaptureSession()
#Published var alert = false
#Published var output = AVCapturePhotoOutput()
#Published var preview : AVCaptureVideoPreviewLayer!
#Published var isLoaded = false
#Published var picData = Data(count: 0)
var flashMode: AVCaptureDevice.FlashMode = .on // set the camera to on
var device : AVCaptureDevice? // for camera device
private func getSettings(camera: AVCaptureDevice, flashMode: AVCaptureDevice.FlashMode) -> AVCapturePhotoSettings {
let settings = AVCapturePhotoSettings() // get the default settings
and change them to enable flash
if camera.hasFlash {
settings.flashMode = self.flashMode
}
return settings
}
func Check() {
switch AVCaptureDevice.authorizationStatus(for: .video){
case .authorized:
setUp()
return
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { (status) in
if status{
self.setUp()
}
}
case .denied:
self.alert.toggle()
return
default:
return
}
}
func setUp(){
do{
self.session.beginConfiguration()
self.device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
let input = try AVCaptureDeviceInput(device: self.device!)
if self.session.canAddInput(input){
self.session.addInput(input)
}
if self.session.canAddOutput(self.output){
self.session.addOutput(self.output)
}
self.session.commitConfiguration()
}
catch{
print(error.localizedDescription)
}
}
func takePic(){
DispatchQueue.global(qos: .background).async {
let currentSettings = self.getSettings(camera: self.device!, flashMode: self.flashMode)
self.output.capturePhoto(with: currentSettings, delegate: self) // Capture photo with flash settings; doesn't work
DispatchQueue.main.async {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false){
(timer) in self.session.stopRunning()
//isBack.setTrue()
}
}
}
DispatchQueue.main.async {
withAnimation{
self.isTaken.toggle()
}
}
}
func reTake() {
DispatchQueue.global(qos: .background).async {
self.session.startRunning()
DispatchQueue.main.async {
withAnimation{
self.isTaken.toggle()
self.isLoaded = false
}
}
}
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) // not sure if there is something wrong here that is messing up the program but need this function to work ultimately{
if error != nil{
print(error!)
}
print("photoOuput function")
print(photo)
guard let imageData = photo.fileDataRepresentation() else{return }
self.picData = imageData
}
func savePic () -> UIImage{
let image = UIImage(data: self.picData)!
self.isLoaded = true
return image
}
}
struct CameraPreview: UIViewRepresentable {
#ObservedObject var camera : CameraModel
func makeUIView(context: Context) -> some UIView {
let view = UIView(frame: UIScreen.main.bounds)
camera.preview = AVCaptureVideoPreviewLayer(session: camera.session)
camera.preview.frame = view.frame
camera.preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(camera.preview)
camera.session.startRunning()
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
I noticed that if I set the following line from above
var flashMode: AVCaptureDevice.FlashMode = .on
to
var flashMode: AVCaptureDevice.FlashMode = .off
the app doesn't produce the above error (but flash stays off). I am asking this because I need to save the output of the camera (with flash) as an image, however, with flash enabled, the picData is nil which leads to an unwrapping error (see the savePic() and photoOutput() functions for reference). Ultimately, I need the savePic() function to work
Any help with this will be appreciated.

ForEach in Scrollview does not get updated when #Published gets changed

My ForEach in a Scrollview does not get updated when CommentViewModel comments get updated. It gets successfully updated, but for some reason, CommentView does not get updated. I have tried everything, but can't seem to find a solution.
Maybe Comment should become a Hashable or Codable. But I can't quite make this work.
I also tried removing the chance of Scrollview being empty, by adding an if statement or empty Text. But this was not the problem.
Any help would be helpfull.
//These are the updated View
struct CommentView: View {
#StateObject var commentViewModel = CommentViewModel()
static let emptyScrollToString = "emptyScrollToString"
#State var commentCommentUser = ""
#State var showCommentComment = false
#State var post: Post
init(_ post: Post) {
self.post = post
}
var body: some View {
VStack {
commentView
Divider()
if showCommentComment {
HStack {
Text("Svarer \(commentCommentUser)")
.foregroundColor(.black)
.font(.system(size: 16))
.opacity(0.3)
Spacer()
Button {
withAnimation(Animation.spring().speed(2)) {
showCommentComment.toggle()
}
} label: {
Text("x")
.font(.system(size: 16))
.foregroundColor(.black)
}
}
.padding()
.background(Color(r: 237, g: 237, b: 237))
}
BottomBar(post: post)
.frame(minHeight: 50,maxHeight: 180)
.fixedSize(horizontal: false, vertical: true)
.shadow(radius: 60)
.navigationBarTitle("Kommentar", displayMode: .inline)
}
.onAppear() {
UINavigationBar.appearance().tintColor = UIColor(red: 20/255, green: 147/255, blue: 2/255, alpha: 1)
commentViewModel.fetchComments(post: post)
}
}
private var commentView: some View {
ScrollView {
ScrollViewReader { scrollViewProxy in
VStack {
HStack{ Spacer() }
.id(Self.emptyScrollToString)
ForEach(commentViewModel.comments, id: \.id) { comment in
CommentCell(post: post, comment: comment, commentCommentUser: $commentCommentUser, showCommentComment: $showCommentComment)
}
}
.onReceive(Just(commentViewModel.comments.count)) { _ in // <-- here
withAnimation(.easeOut(duration: 0.5)) {
print("Scroll to top")
scrollViewProxy.scrollTo(Self.emptyScrollToString, anchor: .bottom)
}
}
}
}
}
public func uploadData(commentText: String) {
guard let uid = FirebaseManager.shared.auth.currentUser?.uid else {return}
guard let id = post.id else {return}
let data = ["fromId":uid, "commentText":commentText, "likes":0, "timestamp": Timestamp()] as [String : Any]
FirebaseManager.shared.firestore.collection("posts").document(id).collection("comments")
.document().setData(data) { error in
if error != nil {
print("failed to post comment", error ?? "")
return
}
print("Update")
commentViewModel.fetchComments(post: post) //Gets error here
}
}
}
struct BottomBar: View {
var commentView: CommentView
init(post: Post) {
self.commentView = CommentView(post)
}
var body: some View {
bottomBar
}
private var bottomBar: some View {
HStack{
TextEditorView(string: $commentText)
.overlay(RoundedRectangle(cornerRadius: 12)
.stroke(lineWidth: 1)
.opacity(0.5))
VStack {
Spacer()
Button {
commentView.uploadData() // This also reset all #State variables in Commentview, for some reason
commentText = ""
} label: {
Text("Slå op")
.font(.system(size: 20, weight: .semibold))
.opacity(commentText.isEmpty ? 0.5 : 1)
.foregroundColor(Color(r: 20, g: 147, b: 2))
}
.padding(.bottom, 10)
}
}
.padding()
}
}
struct Comment: Identifiable, Decodable {
#DocumentID var id: String?
let commentText: String
let fromId: String
var likes: Int
let timestamp: Timestamp
var user: PostUser?
var didLike: Bool? = false
}
class CommentViewModel: ObservableObject {
#Published var comments = [Comment]()
#Published var count = 0
let service: CommentService
let userService = UserService()
init(post: Post) {
self.service = CommentService(post: post)
fetchComments()
}
func fetchComments() {
service.fetchComments { comments in
self.comments = comments
self.count = self.comments.count
for i in 0 ..< comments.count {
let uid = comments[i].fromId
self.userService.fetchUser(withUid: uid) { user in
self.comments[i].user = user
}
}
}
}
}
struct CommentService {
let post: Post
func fetchComments(completion: #escaping([Comment]) -> Void) {
guard let id = post.id else {return}
FirebaseManager.shared.firestore.collection("posts").document(id).collection("comments")
.order(by: "timestamp", descending: true)
.getDocuments { snapshot, error in
if error != nil {
print("failed fetching comments", error ?? "")
return
}
guard let docs = snapshot?.documents else {return}
do {
let comments = try docs.compactMap({ try $0.data(as: Comment.self) })
print("COmplete")
completion(comments)
}
catch {
print("failed")
}
}
}
}
This is the old views
struct Comment: Identifiable, Decodable {
#DocumentID var id: String?
let commentText: String
let fromId: String
var likes: Int
let timestamp: Timestamp
var user: PostUser?
var didLike: Bool? = false
}
class CommentViewModel: ObservableObject {
#Published var comments = [Comment]()
#Published var count = 0
let service: CommentService
let userService = UserService()
init(post: Post) {
self.service = CommentService(post: post)
fetchComments()
}
func fetchComments() {
service.fetchComments { comments in
self.comments = comments
self.count = self.comments.count
for i in 0 ..< comments.count {
let uid = comments[i].fromId
self.userService.fetchUser(withUid: uid) { user in
self.comments[i].user = user
}
}
}
}
}
struct CommentService {
let post: Post
func fetchComments(completion: #escaping([Comment]) -> Void) {
guard let id = post.id else {return}
FirebaseManager.shared.firestore.collection("posts").document(id).collection("comments")
.order(by: "timestamp", descending: true)
.getDocuments { snapshot, error in
if error != nil {
print("failed fetching comments", error ?? "")
return
}
guard let docs = snapshot?.documents else {return}
do {
let comments = try docs.compactMap({ try $0.data(as: Comment.self) })
print("COmplete")
completion(comments)
}
catch {
print("failed")
}
}
}
}
struct CommentView: View {
#ObservedObject var commentViewModel: CommentViewModel
static let emptyScrollToString = "emptyScrollToString"
init(post: Post) {
commentViewModel = CommentViewModel(post: post)
}
var body: some View {
VStack {
commentView
Divider()
BottomBar(post: commentViewModel.service.post)
.frame(minHeight: 50,maxHeight: 180)
.fixedSize(horizontal: false, vertical: true)
.shadow(radius: 60)
.navigationBarTitle("Kommentar", displayMode: .inline)
}
.onAppear() {
UINavigationBar.appearance().tintColor = UIColor(red: 20/255, green: 147/255, blue: 2/255, alpha: 1)
}
}
private var commentView: some View {
ScrollView {
ScrollViewReader { scrollViewProxy in
VStack {
HStack{ Spacer() }
.id(Self.emptyScrollToString)
ForEach(commentViewModel.comments, id: \.id) { comment in // Here should it update
let _ = print("Reload")
CommentCell(post: commentViewModel.service.post, comment: comment)
}
}
.onReceive(commentViewModel.$count) { _ in // It doesn't update here either
withAnimation(.easeOut(duration: 0.5)) {
print("Scroll to top")
scrollViewProxy.scrollTo(Self.emptyScrollToString, anchor: .bottom)
}
}
}
}
}
}
#StateObject property wrapper own the object you created, so it will keep alive once View updated by any changes.
#ObservedObject property wrapper doesn't own the object you created, so it will recreated on View update by any changes, in this way property observer get lost and will not be able to receive changes.
So, changing your ViewModel from #ObservedObject to #StateObject will fix the issue.
EDIT-1: Taking your new code into consideration.
Note, it is important to have a good grip on the SwiftUI basics, especially how to use and pass ObservableObject and how to use Views. I suggest you do the tutorial again.
I have attempted to modify your code to give you an idea on how you could re-structure it. Pay atttention to the details, hope it helps.
Note, I have commented a number of lines, because I do not have Firebase and your other code, such as UserService etc...
Adjust my code to suit your needs, and uncomment the relevant lines.
import Foundation
import SwiftUI
import Combine
struct BottomBar: View {
#ObservedObject var viewModel: CommentViewModel // <-- here
#State var post: Post
#State var commentText = ""
var body: some View {
HStack {
// TextEditorView(string: $commentText)
TextEditor(text: $commentText) // <-- for testing
.overlay(RoundedRectangle(cornerRadius: 12)
.stroke(lineWidth: 1)
.opacity(0.5))
VStack {
Spacer()
Button {
// -- here
viewModel.uploadData(post: post, commentText: commentText)
commentText = ""
} label: {
Text("Slå op")
.font(.system(size: 20, weight: .semibold))
.opacity(commentText.isEmpty ? 0.5 : 1)
}
.padding(.bottom, 10)
}
}
}
}
struct CommentView: View {
#StateObject var commentViewModel = CommentViewModel()
static let emptyScrollToString = "emptyScrollToString"
#State var commentCommentUser = ""
#State var showCommentComment = false
#State var post: Post
var body: some View {
VStack {
commentView
Divider()
if showCommentComment {
HStack {
Text("Svarer \(commentCommentUser)")
.foregroundColor(.black)
.font(.system(size: 16))
.opacity(0.3)
Spacer()
Button {
withAnimation(Animation.spring().speed(2)) {
showCommentComment.toggle()
}
} label: {
Text("x")
.font(.system(size: 16))
.foregroundColor(.black)
}
}.padding()
}
BottomBar(viewModel: commentViewModel, post: post)
.frame(minHeight: 50,maxHeight: 180)
.fixedSize(horizontal: false, vertical: true)
.shadow(radius: 60)
.navigationBarTitle("Kommentar", displayMode: .inline)
}
.onAppear() {
UINavigationBar.appearance().tintColor = UIColor(red: 20/255, green: 147/255, blue: 2/255, alpha: 1)
commentViewModel.fetchComments(post: post)
}
}
private var commentView: some View {
ScrollView {
ScrollViewReader { scrollViewProxy in
VStack {
Spacer()
ForEach(commentViewModel.comments, id: \.id) { comment in
CommentCell(post: post, comment: comment, commentCommentUser: $commentCommentUser, showCommentComment: $showCommentComment)
}
}
.onReceive(Just(commentViewModel.comments.count)) { _ in // <-- here
withAnimation(.easeOut(duration: 0.5)) {
print("Scroll to top")
scrollViewProxy.scrollTo(Self.emptyScrollToString, anchor: .bottom)
}
}
}
}
}
}
// for testing
struct CommentCell: View {
#State var post: Post
#State var comment: Comment
#Binding var commentCommentUser: String
#Binding var showCommentComment: Bool
var body: some View {
Text(comment.commentText) // for testing
}
}
struct Comment: Identifiable, Decodable {
// #DocumentID
var id: String? // for testing
let commentText: String
let fromId: String
var likes: Int
// let timestamp: Timestamp // for testing
var user: PostUser?
var didLike: Bool? = false
}
class CommentViewModel: ObservableObject {
#Published var comments = [Comment]()
let service = CommentService() // <-- here
// let userService = UserService() // for testing
init() { } // <-- here
func fetchComments(post: Post) { // <-- here
service.fetchComments(post: post) { comments in
self.comments = comments
for i in 0 ..< comments.count {
let uid = comments[i].fromId
// self.userService.fetchUser(withUid: uid) { user in
// self.comments[i].user = user
// }
}
}
}
func uploadData(post: Post, commentText: String) {
service.uploadData(post: post, commentText: commentText) { isGood in
if isGood {
self.fetchComments(post: post)
}
}
}
}
struct CommentService {
func fetchComments(post: Post, completion: #escaping([Comment]) -> Void) {
guard let id = post.id else {completion([]); return} // <-- here
// FirebaseManager.shared.firestore.collection("posts").document(id).collection("comments")
// .order(by: "timestamp", descending: true)
// .getDocuments { snapshot, error in
// if error != nil {
// print("failed fetching comments", error ?? "")
// return
// }
// guard let docs = snapshot?.documents else {return}
// do {
// let comments = try docs.compactMap({ try $0.data(as: Comment.self) })
// print("COmplete")
// completion(comments)
// }
// catch {
// print("failed")
// completion([])
// }
// }
}
func uploadData(post: Post, commentText: String, completion: #escaping(Bool) -> Void) {
completion(true) // for testing, to be removed
// guard let uid = FirebaseManager.shared.auth.currentUser?.uid else {completion(false); return} // <--- here
// guard let id = post.id else {completion(false); return} // <--- here
//
// let data = ["fromId":uid, "commentText":commentText, "likes":0, "timestamp": Timestamp()] as [String : Any]
// FirebaseManager.shared.firestore.collection("posts").document(id).collection("comments")
// .document().setData(data) { error in
// if error != nil {
// print("failed to post comment", error ?? "")
// completion(false) // <--- here
// return
// }
// print("Update")
// completion(true) // <--- here
// }
}
}
// for testing
struct PostUser: Identifiable, Decodable {
var id: String?
}
// for testing
struct Post: Identifiable, Decodable {
var id: String?
var name = "something"
}
EDIT-2: typo fix.
in BottomBar changed viewModel.service.uploadData(post: post, commentText: commentText) {_ in}
to viewModel.uploadData(post: post, commentText: commentText)

"CALayer position contains NaN: [nan nan]" error message caused by custom slider

I have a video player that starts playing a video (from firebase URL) and in some cases (70% of cases) i get this error message (exception) when running on physical device (no issues when launching in simulator though):
"CALayer position contains NaN: [nan nan]"
I found that the error doesn't appear when i comment "VideoPlayerControlsView()", so i'm pretty sure the problem is is my CustomerSlider object located insider of this VideoPlayerControlsView view.
I think it may somehow be caused by loading remote video, as the video is not loaded, the app doesn't know the size/bounds of AVPlayer object and therefore some parent view (maybe CustomerSlider) can't be created..
Building a Minimal Reproducible Example would be a nightmare, i just hope some can find a mistake in my code/logic.. If not - gonna build it of course. No other choice.
struct DetailedPlayerView : View {
// The progress through the video, as a percentage (from 0 to 1)
#State private var videoPos: Double = 0
// The duration of the video in seconds
#State private var videoDuration: Double = 0
// Whether we're currently interacting with the seek bar or doing a seek
#State private var seeking = false
private var player: AVPlayer = AVPlayer()
init(item: ExerciseItem, hVideoURL: URL?) {
if hVideoURL != nil {
player = AVPlayer(url: hVideoURL!)
player.isMuted = true
player.play()
} else {
print("[debug] hVideoURL is nil")
}
}
var body: some View {
ZStack {
//VStack {
VideoPlayerView(videoPos: $videoPos,
videoDuration: $videoDuration,
seeking: $seeking,
//timeline: $timeline,
//videoTimeline: videoTimeline,
player: player)
.frame(width: UIScreen.screenHeight, height: UIScreen.screenWidth)
VStack {
Spacer()
VideoPlayerControlsView(videoPos: $videoPos, **<<-----------------------**
videoDuration: $videoDuration,
seeking: $seeking,
player: player)
.frame(width: UIScreen.screenHeight - 2*Constants.scrollPadding, height: 20)
.padding(.bottom, 20)
}
}
.onDisappear {
// When this View isn't being shown anymore stop the player
self.player.replaceCurrentItem(with: nil)
}
}
}
struct VideoPlayerControlsView : View {
#Binding private(set) var videoPos: Double
#Binding private(set) var videoDuration: Double
#Binding private(set) var seeking: Bool
// #Binding private(set) var timeline: [Advice]
#State var shouldStopPlayer: Bool = false
#State var player: AVPlayer
//let player: AVPlayer
#State private var playerPaused = false
var body: some View {
HStack {
// Play/pause button
Button(action: togglePlayPause) {
Image(systemName: playerPaused ? "arrowtriangle.right.fill" : "pause.fill")
.foregroundColor(Color.mainSubtitleColor)
.contentShape(Rectangle())
.padding(.trailing, 10)
}
// Current video time
if videoPos.isFinite && videoPos.isCanonical && videoDuration.isFinite && videoDuration.isCanonical {
Text(Utility.formatSecondsToHMS(videoPos * videoDuration))
.foregroundColor(Color.mainSubtitleColor)
}
// Slider for seeking / showing video progress
CustomSlider(value: $videoPos, shouldStopPlayer: self.$shouldStopPlayer, range: (0, 1), knobWidth: 4) { modifiers in
ZStack {
Group {
Color(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 0.5799999833106995))//Color((red: 0.4, green: 0.3, blue: 1)
.opacity(0.4)
.frame(height: 4)
.modifier(modifiers.barRight)
Color.mainSubtitleColor//Color(red: 0.4, green: 0.3, blue: 1)
.frame(height: 4)
.modifier(modifiers.barLeft)
}
.cornerRadius(5)
VStack {
Image(systemName: "arrowtriangle.down.fill") // SF Symbol
.foregroundColor(Color.mainSubtitleColor)
.offset(y: -3)
}
.frame(width: 20, height: 20)
.contentShape(Rectangle())
.modifier(modifiers.knob)
}
}
.onChange(of: shouldStopPlayer) { _ in
if shouldStopPlayer == false {
print("[debug] shouldStopPlayer == false")
sliderEditingChanged(editingStarted: false)
} else {
if seeking == false {
print("[debug] shouldStopPlayer == true")
sliderEditingChanged(editingStarted: true)
}
}
}
.frame(height: 20)
// Video duration
if videoDuration.isCanonical && videoDuration.isFinite {
Text(Utility.formatSecondsToHMS(videoDuration))
.foregroundColor(Color.mainSubtitleColor)
}
}
.padding(.leading, 40)
.padding(.trailing, 40)
}
private func togglePlayPause() {
pausePlayer(!playerPaused)
}
private func pausePlayer(_ pause: Bool) {
playerPaused = pause
if playerPaused {
player.pause()
}
else {
player.play()
}
}
private func sliderEditingChanged(editingStarted: Bool) {
if editingStarted {
// Set a flag stating that we're seeking so the slider doesn't
// get updated by the periodic time observer on the player
seeking = true
pausePlayer(true)
}
// Do the seek if we're finished
if !editingStarted {
let targetTime = CMTime(seconds: videoPos * videoDuration,
preferredTimescale: 600)
player.seek(to: targetTime) { _ in
// Now the seek is finished, resume normal operation
self.seeking = false
self.pausePlayer(false)
}
}
}
}
extension Double {
func convert(fromRange: (Double, Double), toRange: (Double, Double)) -> Double {
// Example: if self = 1, fromRange = (0,2), toRange = (10,12) -> solution = 11
var value = self
value -= fromRange.0
value /= Double(fromRange.1 - fromRange.0)
value *= toRange.1 - toRange.0
value += toRange.0
return value
}
}
struct CustomSliderComponents {
let barLeft: CustomSliderModifier
let barRight: CustomSliderModifier
let knob: CustomSliderModifier
}
struct CustomSliderModifier: ViewModifier {
enum Name {
case barLeft
case barRight
case knob
}
let name: Name
let size: CGSize
let offset: CGFloat
func body(content: Content) -> some View {
content
.frame(width: (size.width >= 0) ? size.width : 0)
.position(x: size.width*0.5, y: size.height*0.5)
.offset(x: offset)
}
}
struct CustomSlider<Component: View>: View {
#Binding var value: Double
var range: (Double, Double)
var knobWidth: CGFloat?
let viewBuilder: (CustomSliderComponents) -> Component
#Binding var shouldStopPlayer: Bool
init(value: Binding<Double>, shouldStopPlayer: Binding<Bool>, range: (Double, Double), knobWidth: CGFloat? = nil, _ viewBuilder: #escaping (CustomSliderComponents) -> Component
) {
_value = value
_shouldStopPlayer = shouldStopPlayer
self.range = range
self.viewBuilder = viewBuilder
self.knobWidth = knobWidth
}
var body: some View {
return GeometryReader { geometry in
self.view(geometry: geometry) // function below
}
}
private func view(geometry: GeometryProxy) -> some View {
let frame = geometry.frame(in: .global)
let drag = DragGesture(minimumDistance: 0)
.onChanged { drag in
shouldStopPlayer = true
self.onDragChange(drag, frame)
}
.onEnded { drag in
shouldStopPlayer = false
//self.updatedValue = value
print("[debug] slider drag gesture ended, value = \(value)")
}
let offsetX = self.getOffsetX(frame: frame)
let knobSize = CGSize(width: knobWidth ?? frame.height, height: frame.height)
let barLeftSize = CGSize(width: CGFloat(offsetX + knobSize.width * 0.5), height: frame.height)
let barRightSize = CGSize(width: frame.width - barLeftSize.width, height: frame.height)
let modifiers = CustomSliderComponents(
barLeft: CustomSliderModifier(name: .barLeft, size: barLeftSize, offset: 0),
barRight: CustomSliderModifier(name: .barRight, size: barRightSize, offset: barLeftSize.width),
knob: CustomSliderModifier(name: .knob, size: knobSize, offset: offsetX))
return ZStack { viewBuilder(modifiers).gesture(drag) }
}
private func onDragChange(_ drag: DragGesture.Value,_ frame: CGRect) {
let width = (knob: Double(knobWidth ?? frame.size.height), view: Double(frame.size.width))
let xrange = (min: Double(0), max: Double(width.view - width.knob))
var value = Double(drag.startLocation.x + drag.translation.width) // knob center x
value -= 0.5*width.knob // offset from center to leading edge of knob
value = value > xrange.max ? xrange.max : value // limit to leading edge
value = value < xrange.min ? xrange.min : value // limit to trailing edge
value = value.convert(fromRange: (xrange.min, xrange.max), toRange: range)
//print("[debug] slider drag gesture detected, value = \(value)")
self.value = value
}
private func getOffsetX(frame: CGRect) -> CGFloat {
let width = (knob: knobWidth ?? frame.size.height, view: frame.size.width)
let xrange: (Double, Double) = (0, Double(width.view - width.knob))
let result = self.value.convert(fromRange: range, toRange: xrange)
return CGFloat(result)
}
}
some extra code showing how DetailedPlayerView is triggered:
struct DetailedVideo: View {
var item: ExerciseItem
var url: URL
#Binding var isPaused: Bool
var body: some View {
ZStack {
DetailedPlayerView(item: self.item, hVideoURL: url)
//.frame(width: 500, height: 500) //##UPDATED: Apr 10
HStack {
VStack {
ZStack {
//Rectangle 126
RoundedRectangle(cornerRadius: 1)
.fill(Color(#colorLiteral(red: 0.3063802123069763, green: 0.3063802123069763, blue: 0.3063802123069763, alpha: 1)))
.frame(width: 2, height: 20.3)
.rotationEffect(.degrees(-135))
//Rectangle 125
RoundedRectangle(cornerRadius: 1)
.fill(Color(#colorLiteral(red: 0.3063802123069763, green: 0.3063802123069763, blue: 0.3063802123069763, alpha: 1)))
.frame(width: 2, height: 20.3)
.rotationEffect(.degrees(-45))
}
.frame(width: 35, height: 35)//14.4
.contentShape(Rectangle())
.onTapGesture {
print("[debugUI] isPaused = false")
self.isPaused = false
}
.offset(x:20, y:20)
Spacer()
}
Spacer()
}
}
.ignoresSafeArea(.all)
}
}
#ViewBuilder
var detailedVideoView: some View {
if self.hVideoURL != nil {
DetailedVideo(item: self.exerciseVM.exerciseItems[self.exerciseVM.currentIndex], url: self.hVideoURL!, isPaused: self.$exerciseVM.isPaused) // when is paused - we are playing detailed video?
.frame(width: UIScreen.screenHeight, height: UIScreen.screenWidth) //UPDATED: Apr 9, 2021
.onAppear {
AppDelegate.orientationLock = UIInterfaceOrientationMask.landscapeLeft
UIDevice.current.setValue(UIInterfaceOrientation.landscapeLeft.rawValue, forKey: "orientation")
UINavigationController.attemptRotationToDeviceOrientation()
}
.onDisappear {
DispatchQueue.main.async {
AppDelegate.orientationLock = UIInterfaceOrientationMask.portrait
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
UINavigationController.attemptRotationToDeviceOrientation()
}
}
} else {
EmptyView()
}
}

Swiftui ObservableObject with array not update view

I have a problem with Array using ObservableObject in my view. I have an empty array. I call a function at page onAppear. When the data is returned, the view does not update with the new data in array:
class NewsState: ObservableObject {
private let base: String = "api"
let objectWillChange = ObservableObjectPublisher()
#Published var wagsList: Array<UserSlider> = [] {
willSet {
objectWillChange.send()
}
}
func getList() {
let url = NSURL(string: "\(base)/UserApi/getList")
var mutableURLRequest = URLRequest(url: url! as URL)
mutableURLRequest.httpMethod = "GET"
mutableURLRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
AF.request(mutableURLRequest).responseData { response in
guard let data = response.data else { return }
let resp = try! JSONDecoder().decode(Array<UserSlider>.self, from: data)
for i in resp {
let userSlider = UserSlider(id: i.id, uid: i.uid, image: i.image)
self.wagsList.append(userSlider)
}
}
}
}
In my view I have this:
HStack {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
if(self.newsState.wagsList.count != 0) {
ForEach(self.newsState.wagsList, id: \.self) { wags in
VStack {
HStack {
URLImage(URL(string: "\(wags.image)")!, expireAfter: Date(timeIntervalSinceNow: 10)) { proxy in
proxy.image
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipShape(Circle())
.overlay(
RoundedRectangle(cornerRadius: 30)
.stroke(Color.white, lineWidth: 2)
)
.contentShape(Circle())
}.frame(width: 62, height: 62)
}
HStack {
Text("10K")
.foregroundColor(Color.white)
.font(Font.custom("Metropolis-Bold", size: 15))
}
HStack {
Text("followers")
.foregroundColor(Color.white)
.font(Font.custom("Metropolis-Normal", size: 15))
}
}
}
} else {
//loader
}
}.onAppear(perform: initPage)
}
}
What am I doing wrong? I see that the problem is caused by ScrollView.
Try this one
class NewsState: ObservableObject {
private let base: String = "api"
#Published var wagsList: Array<UserSlider> = []
func getList() {
let url = NSURL(string: "\(base)/UserApi/getList")
var mutableURLRequest = URLRequest(url: url! as URL)
mutableURLRequest.httpMethod = "GET"
mutableURLRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
AF.request(mutableURLRequest).responseData { response in
guard let data = response.data else { return }
let resp = try! JSONDecoder().decode(Array<UserSlider>.self, from: data)
let results = resp.map { UserSlider(id: $0.id, uid: $0.uid, image: $0.image) }
DispatchQueue.main.async {
self.wagsList = results
}
}
}
}
As it is not clear to me where the error might lay. It could be either in getList or in your View.
This is an easy example of how it works with a Published and ObserverdObject:
Note: your getList function is not in this solution as the error could be with your API, JSON ect.
import SwiftUI
struct ContentView: View {
#ObservedObject var state = NewsState()
var body: some View {
Group { //needed for the IF Statement below
if state.stringList.count > 0 {
ForEach(self.state.stringList, id: \.self){ s in
Text(String(s))
}
}
}.onTapGesture {
self.state.getNewList()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class NewsState: ObservableObject {
#Published var stringList: Array<String> = []
init() {
self.getList()
}
func getList() {
self.stringList.append("New")
}
func getNewList() {
self.stringList = []
self.stringList.append("New new")
}
}

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
}
}
}
}
}