I can't see the change in the Text object on the ContentView page. However, when I run the same code in .onReceive with print, I can see the change. What's the problem here?
I wanted to manage the state of the game from a different place and the operation of the game from a different place. Is the logic I made wrong?
Enum
enum GameSituation {
case play
}
Game Config
class GameConfig: ObservableObject {
#Published var randomCellValue: Int = 0
#Published var timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
func startGame() {
determineRandomCell()
}
func determineRandomCell() {
randomCellValue = Int.random(in: 0...11)
}
func playSound(soundfile: String, ofType: String) {
if let path = Bundle.main.path(forResource: soundfile, ofType: ofType){
do{
audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
audioPlayer.prepareToPlay()
audioPlayer.play()
} catch {
print("Error")
}
}
}
}
Game Situation
class GameSituations: ObservableObject {
#Published var gameConfig = GameConfig()
func gameSituation(gameSituation: GameSituation) {
switch gameSituation {
case .play:
gameConfig.startGame()
}
}
}
Content View
struct ContentView: View {
#StateObject var gameSituation = GameSituations()
var body: some View {
Text("\(gameSituation.gameConfig.randomCellValue)")
.padding()
.onReceive(gameSituation.gameConfig.timer, perform: { _ in
gameSituation.gameSituation(gameSituation: .play)
print("random: \(gameSituation.gameConfig.randomCellValue)")
})
}
}
You had multi issue I solved all, your first and big issue was, that you are initializing an instance of an ObservableObject but using another one! second one was that you forgot using StateObject of GameConfig in your View, as you can see in your code, you did used StateObject of GameSituations but not GameConfig, if you ask why, because it is Publisher!
My best recommendation: try just use one ObservableObject! using 2 deferent ObservableObject that has binding with together has such a issue that you can see! try organise all your code to one ObservableObject.
enum GameSituation {
case play
}
class GameConfig: ObservableObject {
static let shared: GameConfig = GameConfig() // <<: Here
#Published var randomCellValue: Int = 0
#Published var timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
func startGame() {
determineRandomCell()
}
func determineRandomCell() {
randomCellValue = Int.random(in: 0...11)
}
func playSound(soundfile: String, ofType: String) {
print(soundfile)
}
}
class GameSituations: ObservableObject {
static let shared: GameSituations = GameSituations() // <<: Here
let gameConfig = GameConfig.shared // <<: Here
func gameSituation(gameSituation: GameSituation) {
switch gameSituation {
case .play:
gameConfig.startGame()
}
}
}
struct ContentView: View {
#StateObject var gameSituation = GameSituations.shared // <<: Here
#StateObject var gameConfig = GameConfig.shared // <<: Here
var body: some View {
Text(gameConfig.randomCellValue.description) // <<: Here
.onReceive(gameConfig.timer, perform: { _ in // <<: Here
gameSituation.gameSituation(gameSituation: .play)
print("random: \(gameSituation.gameConfig.randomCellValue)")
})
}
}
Related
I am trying to understand #EnvironmentObject better so I wrote sample code below to replicate the issue i am facing
This is the class where i declare the array which needs to be accessed in multiple locations and be displayed and updated in ContentView
class User: ObservableObject {
#Published var array = [String]()
func diplayName(name: String){
self.array.append(name)
}
}
I want to be able to append my array in another class. Something like the below code
class myTests: ObservableObject {
#EnvironmentObject var user:User
func diplayMyName(name: String){
self.user.array.append(name)
}
}
When I call displayMyName function in myTests class i get an Error message as below
Fatal error: No ObservableObject of type User found.
A View.environmentObject(_:) for User may be missing as an ancestor of this view.
This is how my contentView looks like
struct ContentView: View {
#EnvironmentObject var user:User
var testing = myTests()
var body: some View {
VStack {
List(user.array, id: \.self){ x in
Text(x)
}
Button(action: {
self.user.diplayName(name: "Name1")
// self.testing.diplayMyName(name: "Name2")
}){
Text("Call Function")
}
}
}
}
This is how i declare my environment object in scene delegate
let contentView = ContentView().environmentObject(User())
I would really appreciate if someone can help me understand why am i getting the error when i append the published array from myTests class. Thank you.
UPDATE
To work around my issue i did the following adjustments
I returned an array in myTests class
class myTests {
var ar = [String]()
func displayMyName() -> [String] {
ar.removeAll()
ar.append(contentsOf: ["Name2", "Name3"])
return ar
}
}
And added it to the array in ContentView
struct ContentView: View {
#EnvironmentObject var user : User
var testing = myTests()
var body: some View {
VStack {
List(user.array, id: \.self){ x in
Text(x)
}
Button(action: {
self.user.array.append(contentsOf: self.testing.displayMyName())
}){
Text("Call Function")
}
}
}
}
SwiftUI seems cool, but some things just seem hard to me. Even so, I would rather understand how best to do something the SwiftUI way rather than wrap pre-swiftui controllers and do something the old way. So let me start with a simple problem -- displaying a web image given a URL. There are solutions, but they are not all that easy to find and not all the easy to understand.
I have a solution and would like some feedback. Below is an example of what I would like to do (the images is from Open Images).
struct ContentView: View {
#State var imagePath: String = "https://farm2.staticflickr.com/440/19711210125_6c12414d8f_o.jpg"
var body: some View {
WebImage(imagePath: $imagePath).scaledToFit()
}
}
My solution entails putting a little bit of code at the top of the body to start the image download. The image path has a #Binding property wrapper -- if it changes I want to update my view. There is also a myimage variable with a #State property wrapper -- when it gets set I also want to update my view. If everything goes well with the image load, myimage will be set and the an image displays. The initial problem is that changing the state within the body will result in the view being invalidated and trigger yet another download, ad infinitum. The solution seems simple (the code is below). Just check imagePath and see if it has changed since the last time something was loaded. Note that in download I set prev immediately, which triggers another execution of body. The conditional causes the state change to be ignored.
I read somewhere that #State checks for equality and will ignore sets if the value does not change. This kind of equality check will fail for UIImage. I expect three invocations of body: the initial invocation, the invocation when I set prev, and an invocation when I set image. I suppose I could add a mutable value for prev (i.e., a simple class) and avoid the second invocation.
Note that loading web content could have been accomplished using an extension and closures, but that's a different issue. Doing so, would have shrunk WebImage to just a few lines of code.
So, is there a better way to accomplish this task?
//
// ContentView.swift
// Learn
//
// Created by John Morris on 11/26/19.
// Copyright © 2019 John Morris. All rights reserved.
//
import SwiftUI
struct WebImage: View {
#Binding var imagePath: String?
#State var prev: String?
#State var myimage: UIImage?
#State var message: String?
var body: some View {
if imagePath != prev {
self.downloadImage(from: imagePath)
}
return VStack {
myimage.map({Image(uiImage: $0).resizable()})
message.map({Text("\($0)")})
}
}
init?(imagePath: Binding<String?>) {
guard imagePath.wrappedValue != nil else {
return nil
}
self._imagePath = imagePath
guard let _ = URL(string: self.imagePath!) else {
return nil
}
}
func getData(from url: URL, completion: #escaping (Data?, URLResponse?, Error?) -> ()) {
URLSession.shared.dataTask(with: url, completionHandler: completion).resume()
}
func downloadImage(from imagePath: String?) {
DispatchQueue.main.async() {
self.prev = imagePath
}
guard let imagePath = imagePath, let url = URL(string: imagePath) else {
self.message = "Image path is not URL"
return
}
getData(from: url) { data, response, error in
if let error = error {
self.message = error.localizedDescription
return
}
guard let httpResponse = response as? HTTPURLResponse else {
self.message = "No Response"
return
}
guard (200...299).contains(httpResponse.statusCode) else {
if httpResponse.statusCode == 404 {
self.message = "Page, \(url.absoluteURL), not found"
} else {
self.message = "HTTP Status Code \(httpResponse.statusCode)"
}
return
}
guard let mimeType = httpResponse.mimeType else {
self.message = "No mimetype"
return
}
guard mimeType == "image/jpeg" else {
self.message = "Wrong mimetype"
return
}
print(response.debugDescription)
guard let data = data else {
self.message = "No Data"
return
}
if let image = UIImage(data: data) {
DispatchQueue.main.async() {
self.myimage = image
}
}
}
}
}
struct ContentView: View {
var images = ["https://c1.staticflickr.com/3/2260/5744476392_5d025d6a6a_o.jpg",
"https://c1.staticflickr.com/9/8521/8685165984_e0fcc1dc07_o.jpg",
"https://farm1.staticflickr.com/204/507064030_0d0cbc850c_o.jpg",
"https://farm2.staticflickr.com/440/19711210125_6c12414d8f_o.jpg"
]
#State var imageURL: String?
#State var count = 0
var body: some View {
VStack {
WebImage(imagePath: $imageURL).scaledToFit()
Button(action: {
self.imageURL = self.images[self.count]
self.count += 1
if self.count >= self.images.count {
self.count = 0
}
}) {
Text("Next")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I would suggest two things. First, you generally want to allow a placeholder View for when the image is downloading. Second, you should cache the image otherwise if you have something like a tableView where it scrolls off screen and back on screen, you are going to keep downloading the image over an over again. Here is an example from one of my apps of how I addressed it:
import SwiftUI
import Combine
import UIKit
class ImageCache {
enum Error: Swift.Error {
case dataConversionFailed
case sessionError(Swift.Error)
}
static let shared = ImageCache()
private let cache = NSCache<NSURL, UIImage>()
private init() { }
static func image(for url: URL) -> AnyPublisher<UIImage?, ImageCache.Error> {
guard let image = shared.cache.object(forKey: url as NSURL) else {
return URLSession
.shared
.dataTaskPublisher(for: url)
.tryMap { (tuple) -> UIImage in
let (data, _) = tuple
guard let image = UIImage(data: data) else {
throw Error.dataConversionFailed
}
shared.cache.setObject(image, forKey: url as NSURL)
return image
}
.mapError({ error in Error.sessionError(error) })
.eraseToAnyPublisher()
}
return Just(image)
.mapError({ _ in fatalError() })
.eraseToAnyPublisher()
}
}
class ImageModel: ObservableObject {
#Published var image: UIImage? = nil
var cacheSubscription: AnyCancellable?
init(url: URL) {
cacheSubscription = ImageCache
.image(for: url)
.replaceError(with: nil)
.receive(on: RunLoop.main, options: .none)
.assign(to: \.image, on: self)
}
}
struct RemoteImage : View {
#ObservedObject var imageModel: ImageModel
init(url: URL) {
imageModel = ImageModel(url: url)
}
var body: some View {
imageModel
.image
.map { Image(uiImage:$0).resizable() }
?? Image(systemName: "questionmark").resizable()
}
}
I created a tester app to test adding a GameCenter leaderboard to a simple SwiftUI game I am creating. I have been unable to figure out how to display the Game Center leaderboard with all the scores.
I have created a class containing all the Game Center functions (authentication and adding score to the leaderboard. This is called from the main ContentView view. I can't figure out how to make it show the leaderboard (or even the gamecenter login screen if the player isn't already logged in.)
This is my GameCenterManager class:
class GameCenterManager {
var gcEnabled = Bool() // Check if the user has Game Center enabled
var gcDefaultLeaderBoard = String() // Check the default leaderboardID
var score = 0
let LEADERBOARD_ID = "grp.colorMatcherLeaderBoard_1" //Leaderboard ID from Itunes Connect
// MARK: - AUTHENTICATE LOCAL PLAYER
func authenticateLocalPlayer() {
let localPlayer: GKLocalPlayer = GKLocalPlayer.local
localPlayer.authenticateHandler = {(ViewController, error) -> Void in
if((ViewController) != nil) {
print("User is not logged into game center")
} else if (localPlayer.isAuthenticated) {
// 2. Player is already authenticated & logged in, load game center
self.gcEnabled = true
// Get the default leaderboard ID
localPlayer.loadDefaultLeaderboardIdentifier(completionHandler: { (leaderboardIdentifer, error) in
if error != nil { print(error ?? "error1")
} else { self.gcDefaultLeaderBoard = leaderboardIdentifer! }
})
print("Adding GameCenter user was a success")
} else {
// 3. Game center is not enabled on the users device
self.gcEnabled = false
print("Local player could not be authenticated!")
print(error ?? "error2")
}
}
} //authenticateLocalPlayer()
func submitScoreToGC(_ score: Int){
let bestScoreInt = GKScore(leaderboardIdentifier: LEADERBOARD_ID)
bestScoreInt.value = Int64(score)
GKScore.report([bestScoreInt]) { (error) in
if error != nil {
print(error!.localizedDescription)
} else {
print("Best Score submitted to your Leaderboard!")
}
}
}//submitScoreToGc()
}
and here is the ContentView struct:
struct ContentView: View {
//GameCenter
init() {
self.gameCenter = GameCenterManager()
self.gameCenter.authenticateLocalPlayer()
}
#State var score = 0
var gcEnabled = Bool() //Checks if the user had enabled GameCenter
var gcDefaultLeaderboard = String() //Checks the default leaderboard ID
let gameCenter: GameCenterManager
/*End GameCenter Variables */
var body: some View {
HStack {
Text("Hello, world!")
Button(action: {
self.score += 1
print("Score increased by 10. It is now \(self.score)")
self.gameCenter.submitScoreToGC(self.score)
}) {
Text("Increase Score")
}
}
}
}
Would greatly appreciate any help in fixing the problem.
I have a fix.
I use Game Center successfully in my SwiftUI App Sound Matcher. Code snippets to follow.
The code doesn't exactly follow the SwiftUI declarative philosophy but it works perfectly. I added snippets to SceneDelegate and ContentView plus used I used a GameKitHelper class similar to the one Thomas created for his test app. I based my version on code I found on raywenderlich.com.
I actually tried using a struct conforming to UIViewControllerRepresentable as my first attempt, following the same line of thought as bg2b, however it kept complaining that the game centre view controller needed to be presented modally. Eventually I gave up and tried my current more successful approach.
For SwiftUI 1.0 and iOS 13
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let contentView = ContentView()
.environmentObject(GameKitHelper.sharedInstance) // publish enabled state
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
// new code to create listeners for the messages
// you will be sending later
PopupControllerMessage.PresentAuthentication
.addHandlerForNotification(
self,
handler: #selector(SceneDelegate
.showAuthenticationViewController))
PopupControllerMessage.GameCenter
.addHandlerForNotification(
self,
handler: #selector(SceneDelegate
.showGameCenterViewController))
// now we are back to the standard template
// generated when your project was created
self.window = window
window.makeKeyAndVisible()
}
}
// pop's up the leaderboard and achievement screen
#objc func showGameCenterViewController() {
if let gameCenterViewController =
GameKitHelper.sharedInstance.gameCenterViewController {
self.window?.rootViewController?.present(
gameCenterViewController,
animated: true,
completion: nil)
}
}
// pop's up the authentication screen
#objc func showAuthenticationViewController() {
if let authenticationViewController =
GameKitHelper.sharedInstance.authenticationViewController {
self.window?.rootViewController?.present(
authenticationViewController, animated: true)
{ GameKitHelper.sharedInstance.enabled =
GameKitHelper.sharedInstance.gameCenterEnabled }
}
}
}
// content you want your app to display goes here
struct ContentView: View {
#EnvironmentObject var gameCenter : GameKitHelper
#State private var isShowingGameCenter = false { didSet {
PopupControllerMessage
.GameCenter
.postNotification() }}
var body: some View {
VStack {
if self.gameCenter.enabled
{
Button(action:{ self.isShowingGameCenter.toggle()})
{ Text(
"Press to show leaderboards and achievements")}
}
// The authentication popup will appear when you first enter
// the view
}.onAppear() {GameKitHelper.sharedInstance
.authenticateLocalPlayer()}
}
}
import GameKit
import UIKit
// Messages sent using the Notification Center to trigger
// Game Center's Popup screen
public enum PopupControllerMessage : String
{
case PresentAuthentication = "PresentAuthenticationViewController"
case GameCenter = "GameCenterViewController"
}
extension PopupControllerMessage
{
public func postNotification() {
NotificationCenter.default.post(
name: Notification.Name(rawValue: self.rawValue),
object: self)
}
public func addHandlerForNotification(_ observer: Any,
handler: Selector) {
NotificationCenter.default .
addObserver(observer, selector: handler, name:
NSNotification.Name(rawValue: self.rawValue), object: nil)
}
}
// based on code from raywenderlich.com
// helper class to make interacting with the Game Center easier
open class GameKitHelper: NSObject, ObservableObject, GKGameCenterControllerDelegate {
public var authenticationViewController: UIViewController?
public var lastError: Error?
private static let _singleton = GameKitHelper()
public class var sharedInstance: GameKitHelper {
return GameKitHelper._singleton
}
private override init() {
super.init()
}
#Published public var enabled :Bool = false
public var gameCenterEnabled : Bool {
return GKLocalPlayer.local.isAuthenticated }
public func authenticateLocalPlayer () {
let localPlayer = GKLocalPlayer.local
localPlayer.authenticateHandler = {(viewController, error) in
self.lastError = error as NSError?
self.enabled = GKLocalPlayer.local.isAuthenticated
if viewController != nil {
self.authenticationViewController = viewController
PopupControllerMessage
.PresentAuthentication
.postNotification()
}
}
}
public var gameCenterViewController : GKGameCenterViewController? { get {
guard gameCenterEnabled else {
print("Local player is not authenticated")
return nil }
let gameCenterViewController = GKGameCenterViewController()
gameCenterViewController.gameCenterDelegate = self
gameCenterViewController.viewState = .achievements
return gameCenterViewController
}}
open func gameCenterViewControllerDidFinish(_
gameCenterViewController: GKGameCenterViewController) {
gameCenterViewController.dismiss(
animated: true, completion: nil)
}
}
Update: For SwiftUI 2.0 and iOS 14 the code is lot easier
import GameKit
enum Authenticate
{
static func user() {
let localPlayer = GKLocalPlayer.local
localPlayer.authenticateHandler = { _, error in
guard error == nil else {
print(error?.localizedDescription ?? "")
return
}
GKAccessPoint.shared.location = .topLeading
GKAccessPoint.shared.isActive =
localPlayer.isAuthenticated
}
}
}
import SwiftUI
// content you want your app to display goes here
struct ContentView: View {
var body: some View {
Text( "Start Game")
// The authentication popup will appear when you first enter
// the view
}.onAppear() { Authenticate.user()}
}
}
EDIT 2023: as mentioned in comments, GKScore is now deprecated. I don't have an updated solution to present.
Partial answer for you here. I'm able to download leaderboard scores and display them in a SwiftUI list provided the device (or simulator) is logged into iCloud and has GameCenter already enabled in settings. I have not attempted to make a gameCenter authentication view controller appear if that is not the case.
Thank you for the code in your question. I used your GameCenterManager() but put it in my AppDelegate:
let gameCenter = GameCenterManager()
Below is my ShowRankings.swift SwiftUI View. I'm able to successfully authenticate and get the scores. But I still have "anomalies". The first time I run this (in simulator) I get the expected "User is not logged into Game Center" error indicating the ViewController in your GameCenterManager is not nil (I never even attempt to display it). But then I'm able to successfully get the scores and display them in a list.
import SwiftUI
import GameKit
struct ShowRankings: View {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
let leaderBoard = GKLeaderboard()
#State var scores: [GKScore] = []
var body: some View {
VStack {
Button(action: {
self.updateLeader()
}) {
Text("Refresh leaderboard")
}
List(scores, id: \.self) { score in
Text("\(score.player.alias) \(score.value)")
}
}.onAppear() {
self.appDelegate.gameCenter.authenticateLocalPlayer()
self.updateLeader()
}
}
func updateLeader() {
let leaderBoard: GKLeaderboard = GKLeaderboard()
leaderBoard.identifier = "YOUR_LEADERBOARD_ID_HERE"
leaderBoard.timeScope = .allTime
leaderBoard.loadScores { (scores, error) in
if let error = error {
debugPrint("leaderboard loadScores error \(error)")
} else {
guard let scores = scores else { return }
self.scores = scores
}
}
}
}
An alternative solution is to create a UIViewControllerRepresentable for GameCenter which takes a leaderboard ID to open. This makes it simple to open a specific leader board.
public struct GameCenterView: UIViewControllerRepresentable {
let viewController: GKGameCenterViewController
public init(leaderboardID : String?) {
if leaderboardID != nil {
self.viewController = GKGameCenterViewController(leaderboardID: leaderboardID!, playerScope: GKLeaderboard.PlayerScope.global, timeScope: GKLeaderboard.TimeScope.allTime)
}
else{
self.viewController = GKGameCenterViewController(state: GKGameCenterViewControllerState.leaderboards)
}
}
public func makeUIViewController(context: Context) -> GKGameCenterViewController {
let gkVC = viewController
gkVC.gameCenterDelegate = context.coordinator
return gkVC
}
public func updateUIViewController(_ uiViewController: GKGameCenterViewController, context: Context) {
return
}
public func makeCoordinator() -> GKCoordinator {
return GKCoordinator(self)
}
}
public class GKCoordinator: NSObject, GKGameCenterControllerDelegate {
var view: GameCenterView
init(_ gkView: GameCenterView) {
self.view = gkView
}
public func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) {
gameCenterViewController.dismiss(animated: true, completion: nil)
}
}
To use just add the below wherever it is needed to display a leaderboard.
GameCenterView(leaderboardID: "leaderBoardID")
I am trying to make individually moveable objects. I am able to successfully do it for one object but once I place it into an array, the objects are not able to move anymore.
Model:
class SocialStore: ObservableObject {
#Published var socials : [Social]
init(socials: [Social]){
self.socials = socials
}
}
class Social : ObservableObject{
var id: Int
var imageName: String
var companyName: String
#Published var pos: CGPoint
init(id: Int, imageName: String, companyName: String, pos: CGPoint) {
self.id = id
self.imageName = imageName
self.companyName = companyName
self.pos = pos
}
var dragGesture : some Gesture {
DragGesture()
.onChanged { value in
self.pos = value.location
print(self.pos)
}
}
}
Multiple image (images not following drag):
struct ContentView : View {
#ObservedObject var socialObject: SocialStore = SocialStore(socials: testData)
#ObservedObject var images: Social = testData[2]
var body: some View {
VStack {
ForEach(socialObject.socials, id: \.id) { social in
Image(social.imageName)
.position(social.pos)
.gesture(social.dragGesture)
}
}
}
}
Single image (image follow gesture):
struct ContentView : View {
#ObservedObject var socialObject: SocialStore = SocialStore(socials: testData)
#ObservedObject var images: Social = testData[2]
var body: some View {
VStack {
Image(images.imageName)
.position(images.pos)
.gesture(images.dragGesture)
}
}
}
I expect the individual items to be able to move freely . I see that the coordinates are updating but the position of each image is not.
First, a disclaimer: The code below is not meant as a copy-and-paste solution. Its only goal is to help you understand the challenge. There may be more efficient ways of resolving it, so take your time to think of your implementation once you understand the problem.
Why the view does not update?: The #Publisher in SocialStore will only emit an update when the array changes. Since nothing is being added or removed from the array, nothing will happen. Additionally, because the array elements are objects (and not values), when they do change their position, the array remains unaltered, because the reference to the objects remains the same. Remember: Classes create objects, Structs create values.
We need a way of making the store, to emit a change when something in its element changes. In the example below, your store will subscribe to each of its elements bindings. Now, all published updates from your items, will be relayed to your store publisher, and you will obtain the desired result.
import SwiftUI
import Combine
class SocialStore: ObservableObject {
#Published var socials : [Social]
var cancellables = [AnyCancellable]()
init(socials: [Social]){
self.socials = socials
self.socials.forEach({
let c = $0.objectWillChange.sink(receiveValue: { self.objectWillChange.send() })
// Important: You have to keep the returned value allocated,
// otherwise the sink subscription gets cancelled
self.cancellables.append(c)
})
}
}
class Social : ObservableObject{
var id: Int
var imageName: String
var companyName: String
#Published var pos: CGPoint
init(id: Int, imageName: String, companyName: String, pos: CGPoint) {
self.id = id
self.imageName = imageName
self.companyName = companyName
self.pos = pos
}
var dragGesture : some Gesture {
DragGesture()
.onChanged { value in
self.pos = value.location
print(self.pos)
}
}
}
struct ContentView : View {
#ObservedObject var socialObject: SocialStore = SocialStore(socials: testData)
var body: some View {
VStack {
ForEach(socialObject.socials, id: \.id) { social in
Image(social.imageName)
.position(social.pos)
.gesture(social.dragGesture)
}
}
}
}
For those who might find it helpful. This is a more generic approach to #kontiki 's answer.
This way you will not have to be repeating yourself for different model class types.
import Foundation
import Combine
import SwiftUI
class ObservableArray<T>: ObservableObject {
#Published var array:[T] = []
var cancellables = [AnyCancellable]()
init(array: [T]) {
self.array = array
}
func observeChildrenChanges<K>(_ type:K.Type) throws ->ObservableArray<T> where K : ObservableObject{
let array2 = array as! [K]
array2.forEach({
let c = $0.objectWillChange.sink(receiveValue: { _ in self.objectWillChange.send() })
// Important: You have to keep the returned value allocated,
// otherwise the sink subscription gets cancelled
self.cancellables.append(c)
})
return self
}
}
class Social : ObservableObject{
var id: Int
var imageName: String
var companyName: String
#Published var pos: CGPoint
init(id: Int, imageName: String, companyName: String, pos: CGPoint) {
self.id = id
self.imageName = imageName
self.companyName = companyName
self.pos = pos
}
var dragGesture : some Gesture {
DragGesture()
.onChanged { value in
self.pos = value.location
print(self.pos)
}
}
}
struct ContentView : View {
//For observing changes to the array only.
//No need for model class(in this case Social) to conform to ObservabeObject protocol
#ObservedObject var socialObject: ObservableArray<Social> = ObservableArray(array: testData)
//For observing changes to the array and changes inside its children
//Note: The model class(in this case Social) must conform to ObservableObject protocol
#ObservedObject var socialObject: ObservableArray<Social> = try! ObservableArray(array: testData).observeChildrenChanges(Social.self)
var body: some View {
VStack {
ForEach(socialObject.array, id: \.id) { social in
Image(social.imageName)
.position(social.pos)
.gesture(social.dragGesture)
}
}
}
}
There are two ObservableObject types and the one that you are interested in is Combine.ObservableObject. It requires an objectWillChange variable of type ObservableObjectPublisher and it is this that SwiftUI uses to trigger a new rendering. I am not sure what Foundation.ObservableObject is used for but it is confusing.
#Published creates a PassthroughSubject publisher that can be connected to a sink somewhere else but which isn't useful to SwiftUI, except for .onReceive() of course.
You need to implement
let objectWillChange = ObservableObjectPublisher()
in your ObservableObject class
I've been looking through the docs with each beta but haven't seen a way to make a traditional paged ScrollView. I'm not familiar with AppKit so I am wondering if this doesn't exist in SwiftUI because it's primarily a UIKit construct. Anyway, does anyone have an example of this, or can anyone tell me it's definitely impossible so I can stop looking and roll my own?
You can now use a TabView and set the .tabViewStyle to PageTabViewStyle()
TabView {
View1()
View2()
View3()
}
.tabViewStyle(PageTabViewStyle())
As of Beta 3 there is no native SwiftUI API for paging. I've filed feedback and recommend you do the same. They changed the ScrollView API from Beta 2 to Beta 3 and I wouldn't be surprised to see a further update.
It is possible to wrap a UIScrollView in order to provide this functionality now. Unfortunately, you must wrap the UIScrollView in a UIViewController, which is further wrapped in UIViewControllerRepresentable in order to support SwiftUI content.
Gist here
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
And then to use it:
var body: some View {
GeometryReader { proxy in
UIScrollViewWrapper {
VStack {
ForEach(0..<1000) { _ in
Text("Hello world")
}
}
.frame(width: proxy.size.width) // This ensures the content uses the available width, otherwise it will be pinned to the left
}
}
}
Apple's official tutorial covers this as an example. I find it easy to follow and suitable for my case. I really recommend you check this out and try to understand how to interface with UIKit. Since SwiftUI is so young, not every feature in UIKit would be covered at this moment. Interfacing with UIKit should address most if not all needs.
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit
Not sure if this helps your question but for the time being while Apple is working on adding a Paging View in SwiftUI I've written a utility library that gives you a SwiftUI feel while using a UIPageViewController under the hood tucked away.
You can use it like this:
Pages {
Text("Page 1")
Text("Page 2")
Text("Page 3")
Text("Page 4")
}
Or if you have a list of models in your application you can use it like this:
struct Car {
var model: String
}
let cars = [Car(model: "Ford"), Car(model: "Ferrari")]
ModelPages(cars) { index, car in
Text("The \(index) car is a \(car.model)")
.padding(50)
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
}
You can simply track state using .onAppear() to load your next page.
struct YourListView : View {
#ObservedObject var viewModel = YourViewModel()
let numPerPage = 50
var body: some View {
NavigationView {
List(viewModel.items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
.onAppear {
if self.shouldLoadNextPage(currentItem: item) {
self.viewModel.fetchItems(limitPerPage: self.numPerPage)
}
}
}
}
.navigationBarTitle(Text("Items"))
.onAppear {
guard self.viewModel.items.isEmpty else { return }
self.viewModel.fetchItems(limitPerPage: self.numPerPage)
}
}
}
private func shouldLoadNextPage(currentItem item: Item) -> Bool {
let currentIndex = self.viewModel.items.firstIndex(where: { $0.id == item.id } )
let lastIndex = self.viewModel.items.count - 1
let offset = 5 //Load next page when 5 from bottom, adjust to meet needs
return currentIndex == lastIndex - offset
}
}
class YourViewModel: ObservableObject {
#Published private(set) items = [Item]()
// add whatever tracking you need for your paged API like next/previous and count
private(set) var fetching = false
private(set) var next: String?
private(set) var count = 0
func fetchItems(limitPerPage: Int = 30, completion: (([Item]?) -> Void)? = nil) {
// Do your stuff here based on the API rules for paging like determining the URL etc...
if items.count == 0 || items.count < count {
let urlString = next ?? "https://somePagedAPI?limit=/(limitPerPage)"
fetchNextItems(url: urlString, completion: completion)
} else {
completion?(pokemon)
}
}
private func fetchNextItems(url: String, completion: (([Item]?) -> Void)?) {
guard !fetching else { return }
fetching = true
Networking.fetchItems(url: url) { [weak self] (result) in
DispatchQueue.main.async { [weak self] in
self?.fetching = false
switch result {
case .success(let response):
if let count = response.count {
self?.count = count
}
if let newItems = response.results {
self?.items += newItems
}
self?.next = response.next
case .failure(let error):
// Error state tracking not implemented but would go here...
os_log("Error fetching data: %#", error.localizedDescription)
}
}
}
}
}
Modify to fit whatever API you are calling and handle errors based on your app architecture.
Checkout SwiftUIPager. It's a pager built on top of SwiftUI native components:
If you would like to exploit the new PageTabViewStyle of TabView, but you need a vertical paged scroll view, you can make use of effect modifiers like .rotationEffect().
Using this method I wrote a library called VerticalTabView 🔝 that turns a TabView vertical just by changing your existing TabView to VTabView.
You can use such custom modifier:
struct ScrollViewPagingModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
.onDisappear {
UIScrollView.appearance().isPagingEnabled = false
}
}
}
extension ScrollView {
func isPagingEnabled() -> some View {
modifier(ScrollViewPagingModifier())
}
}
To simplify Lorenzos answer, you can basically add UIScrollView.appearance().isPagingEnabled = true to your scrollview as below:
VStack{
ScrollView(showsIndicators: false){
VStack(spacing: 0){ // to remove spacing between rows
ForEach(1..<10){ i in
ZStack{
Text(String(i))
Circle()
} .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
}
}
}.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
.onDisappear {
UIScrollView.appearance().isPagingEnabled = false
}
}