SwiftUI: How to create multiple views in various positions with timer? - view

It is pretty easy to get ready the following task in the old-school swift: every three seconds a new view (subview) appears in a new position. Here is a code:
import UIKit
class ViewController: UIViewController {
var someView = UIView()
var posX : CGFloat = 10
var posY : CGFloat = 10
var timer:Timer!
var loopCount = 1
override func viewDidLoad() {
super.viewDidLoad()
startTimer()
view.backgroundColor = .purple
}
func setView() {
someView = UIView(frame: CGRect(x: posX, y: posY, width: 10, height: 10))
someView.backgroundColor = UIColor.orange
view.addSubview(someView)
}
func startTimer() {
if timer == nil {
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(loop), userInfo: nil, repeats: true)
}
}
#objc func loop(){
if (loopCount % 3 == 0) {
posX += 15
posY += 15
setView()
}
loopCount += 1
}
}
SwiftUI makes many things much easier, but not this one, I’m afraid. At least I could not find an easy way to solve it until now. Has somebody any idea?
Here is screen with the result (after several seconds):

Here is possible approach (tested with Xcode 11.2 / iOS 13.2). SwiftUI is reactive concept, so instead of add view itself, it is added a new position into view model and SwiftUI view in response to this change in view model refresh itself addition new view (in this case Rectangle) at new added position.
Demo (moment of start recording is not accurate, but rects added regularly):
Code: (see also some comments inline)
// needed to use as ID in ForEach
extension CGPoint: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(self.x)
hasher.combine(self.y)
}
}
// View model holding and generating new positions
class DemoViewModel: ObservableObject {
#Published var positions = [CGPoint]() // all points for view
private var loopCount = 0
func loop() {
if (loopCount % 3 == 0) {
if let last = positions.last { // generate new point
positions.append(CGPoint(x: last.x + 15, y: last.y + 15))
} else {
positions.append(CGPoint(x: 10, y: 10))
}
}
loopCount += 1
}
}
struct DemoAddingView: View {
#ObservedObject var vm = DemoViewModel()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
ForEach(vm.positions, id: \.self) { position in
Rectangle().fill(Color.orange) // just generate a rect view for all points
.frame(width: 10, height: 10)
.position(position) // location of rect in global coordinates
}
.onReceive(timer) { _ in
self.vm.loop() // add next point
}
}
}
}

Related

SwiftUI Animation Circle with Colors

My problem is simple I think but I can't figure how solve it.
I've this :
struct ArcSelectionView: View {
#Binding var isShowing: Bool
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State var colors = [Color.blue, Color.red, Color.green, Color.yellow]
var body: some View {
ZStack {
ForEach(1 ..< 5, id: \.self) { item in
Circle()
.trim(from: self.isShowing ? CGFloat((Double(item) * 0.25) - 0.25) : CGFloat(Double(item) * 0.25),
to: CGFloat(Double(item) * 0.25))
.stroke(self.colors[item - 1], lineWidth: 50)
.frame(width: 300, height: 300)
.onTapGesture {
if colors[item - 1] == colorToPress {
score += 1
}
isShowing.toggle()
colorToPress = colors.randomElement() ?? Color.offWhite
colors.shuffle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.isShowing.toggle()
}
}
}
}
.opacity(self.isShowing ? 1 : 0)
.rotationEffect(.degrees(self.isShowing ? 0 : 180))
.animation(.linear(duration: 0.35))
}
}
If I didn't shuffle colors in the .onTapGesture, everything is ok. But If I do, I've a strange plain Circle that appears in the middle and disappear after. It's ugly. Ugly Circle
Thank you for your help !
The issue is with the animation of the Circles. The better solution is to use arc shapes. Here is a working solution:
struct ArcSelectionView: View {
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State private var colors = [Color.blue, Color.red, Color.green, Color.yellow]
#State private var pct: CGFloat = 0.25
#State private var originalPCT: CGFloat = 0.25
let duration: Double = 0.35
var body: some View {
ZStack {
CircleView(wedge: originalPCT)
// I am not sure why, but at there is a difference of 10 in the sizes of the
// circle and the modifier. This corrects for it so the touch is accurate.
.frame(width: 310, height: 310)
PercentageArc(Color.clear, colors: colors, pct: pct) {
// With this solution you must have the callback sent to
// the main thread. This was unnecessary with AnimatbleModifier.
DispatchQueue.main.async {
pct = originalPCT
}
}
.animation(.linear(duration: duration), value: pct)
.frame(width: 300, height: 300)
// This forces the view to ignore taps.
.allowsHitTesting(false)
}
.onAppear {
pct = 1.0 / CGFloat(colors.count)
originalPCT = pct
}
}
func CircleView(wedge: CGFloat) -> some View {
ZStack {
// Array(zip()) is a cleaner and safe way of using indices AND you
// have the original object to use as well.
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
Circle()
.trim(from: CGFloat((Double(index) * wedge)),
to: CGFloat(Double(index + 1) * wedge))
// The color of the stroke should match your background color.
// Clear won't work.
.stroke(.white, lineWidth: 50)
.onTapGesture {
if color == colorToPress {
score += 1
print("score!")
}
pct = 0
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
colorToPress = colors.randomElement() ?? .white
colors.shuffle()
}
}
}
}
}
}
struct PercentageArc<Content>: View, Animatable where Content: View {
private var content: Content
private var colors: [Color]
private var pct: CGFloat
private var target: CGFloat
private var onEnded: () -> ()
init(_ content: Content, colors: [Color], pct: CGFloat, onEnded: #escaping () -> () = {}) {
self.content = content
self.colors = colors
self.pct = pct
self.target = pct
self.onEnded = onEnded
}
var animatableData: CGFloat {
get { pct }
set { pct = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
if newValue == target {
onEnded()
}
}
}
var body: some View {
content
.overlay(
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
ArcPortionShape(pct: pct, startAngle: .degrees(1.0 / CGFloat(colors.count) * CGFloat(index) * 360.0))
.foregroundColor(color)
}
)
}
struct ArcPortionShape: InsettableShape {
let pct: CGFloat
let startAngle: Angle
var insetAmount = 0.0
init(pct: CGFloat, startAngle: Angle) {
self.pct = pct
self.startAngle = startAngle
}
var portion: CGFloat {
pct * 360.0
}
var endAngle: Angle {
.degrees(startAngle.degrees + portion)
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
radius: rect.height / 2.0 + 5.0,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
return p.strokedPath(.init(lineWidth: 50))
}
func inset(by amount: CGFloat) -> some InsettableShape {
var arc = self
arc.insetAmount += amount
return arc
}
}
}
Originally, I made this with an AnimatableModifier, but it is deprecated, and the solution using it fails if it is placed in ANY stack or NavigationView. I can see why AnimatableModifier is deprecated.
This solution draws inspiration from this answer from Asperi, for the callback idea, though the solution will not work in iOS 15.2.

Reload SwiftUI view from within it

I have some text inside a View and I want this text to change it's position on timer. I have the following:
struct AlphabetView: View {
#State var timer: Publishers.Autoconnect<Timer.TimerPublisher> = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
#State var refreshMe: Bool = false // this is a hack to refresh the view
var relativePositionX: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var relativePositionY: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var body: some View {
GeometryReader { geometry in
Text("Hello World!")
.position(x: geometry.size.width * self.relativePositionX, y: geometry.size.height * self.relativePositionY)
.onReceive(timer) { _ in
// a hack to refresh the view
self.refreshMe = !self.refreshMe
}
}
}
}
I suppose that the View should be reloaded every time self.refreshMe changes, because it is #State. But what I see is that the text position changes only when the parent view of the view in question is reloaded. What am I doing wrong (except hacking around)? Is there a better way to do this?
Instead of hack just update position directly.
Here is corrected variant. Tested with Xcode 12 / iOS 14.
struct AlphabetView: View {
let timer: Publishers.Autoconnect<Timer.TimerPublisher> = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
#State var point: CGPoint = .zero
var relativePositionX: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var relativePositionY: CGFloat {
get { return CGFloat(Float.random(in: 0...1)) }
}
var body: some View {
GeometryReader { geometry in
Text("Hello World!")
.position(self.point)
.onReceive(timer) { _ in
self.point = CGPoint(x: geometry.size.width * self.relativePositionX, y: geometry.size.height * self.relativePositionY)
}
}
}
}

Continuously Redrawing a Path with Updated Data

I am developing an audio visualizer MacOS app, and I want to use Quartz/CoreGraphics to render the time-varying spectrum coordinated with the playing audio. My Renderer code is:
import Cocoa
class Renderer: NSView {
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
NSColor.white.setFill()
bounds.fill()
guard let context = NSGraphicsContext.current?.cgContext else {return}
var x : CGFloat = 0.0
var y : CGFloat = 0.0
context.beginPath()
context.move(to: CGPoint(x: x, y: y))
for bin in 0 ..< 300 {
x = CGFloat(bin)
y = CGFloat(Global.spectrum[bin])
context.addLine(to: CGPoint(x: x, y: y))
}
context.setStrokeColor(CGColor( red: 1, green: 0, blue: 0, alpha: 1))
context.setLineWidth(1.0)
context.strokePath()
self.setNeedsDisplay(dirtyRect)
}
}
This draws the path once - using the initial all-zeroes values of the spectrum[] array - and then continues to draw that same all-zeroes line indefinitely. It does not update using the new values in the spectrum[] array. I used a print() statement to verify that the values themselves are being updated, but the draw function does not redraw the path using the updated spectrum values. What am I doing wrong?
The following demo shows how to update an NSView with random numbers created by a timer in a separate class to hopefully mimic your project. It may be run in Xcode by setting up a Swift project for MacOS, copy/pasting the source code into a new file called 'main.swift', and deleting the AppDelegate supplied by Apple. A draw function similar to what you posted is used.
import Cocoa
var view : NSView!
var data = [Int]()
public extension Array where Element == Int {
static func generateRandom(size: Int) -> [Int] {
guard size > 0 else {
return [Int]()
}
return Array(0..<size).shuffled()
}
}
class DataManager: NSObject {
var timer:Timer!
#objc func fireTimer() {
data = Array.generateRandom(size:500)
view.needsDisplay = true
}
func startTimer(){
timer = Timer.scheduledTimer(timeInterval: 2.0, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true)
}
func stopTimer() {
timer?.invalidate()
}
}
let dataMgr = DataManager()
class View: NSView {
override func draw(_ rect: NSRect) {
super.draw(rect)
NSColor.white.setFill()
bounds.fill()
guard let gc = NSGraphicsContext.current?.cgContext else {return}
var xOld : CGFloat = 0.0
var yOld : CGFloat = 0.0
var xNew : CGFloat = 0.0
var yNew : CGFloat = 0.0
var counter : Int = 0
gc.beginPath()
gc.move(to: CGPoint(x: xOld, y: yOld))
for i in 0 ..< data.count {
xNew = CGFloat(counter)
yNew = CGFloat(data[i])
gc.addLine(to: CGPoint(x: xNew, y: yNew))
xOld = xNew;
yOld = yNew;
counter = counter + 1
}
gc.setStrokeColor(CGColor( red: 1, green: 0, blue: 0, alpha: 1))
gc.setLineWidth(1.0)
gc.strokePath()
}
}
class ApplicationDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
#objc func myStartAction(_ sender:AnyObject ) {
dataMgr.startTimer()
}
#objc func myStopAction(_ sender:AnyObject ) {
dataMgr.stopTimer()
}
func buildMenu() {
let mainMenu = NSMenu()
NSApp.mainMenu = mainMenu
// **** App menu **** //
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "Quit", action:#selector(NSApplication.terminate), keyEquivalent: "q")
}
func buildWnd() {
data = Array.generateRandom(size: 500)
let _wndW : CGFloat = 800
let _wndH : CGFloat = 600
window = NSWindow(contentRect: NSMakeRect( 0, 0, _wndW, _wndH ), styleMask:[.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
window.center()
window.title = "Swift Test Window"
window.makeKeyAndOrderFront(window)
// **** Start Button **** //
let startBtn = NSButton (frame:NSMakeRect( 30, 20, 95, 30 ))
startBtn.bezelStyle = .rounded
startBtn.title = "Start"
startBtn.action = #selector(self.myStartAction(_:))
window.contentView!.addSubview (startBtn)
// **** Stop Button **** //
let stopBtn = NSButton (frame:NSMakeRect( 230, 20, 95, 30 ))
stopBtn.bezelStyle = .rounded
stopBtn.title = "Stop"
stopBtn.action = #selector(self.myStopAction(_:))
window.contentView!.addSubview (stopBtn)
// **** Custom view **** //
view = View( frame:NSMakeRect(20, 60, _wndW - 40, _wndH - 80))
view.autoresizingMask = [.width, .height]
window.contentView!.addSubview (view)
// **** Quit btn **** //
let quitBtn = NSButton (frame:NSMakeRect( _wndW - 50, 10, 40, 40 ))
quitBtn.bezelStyle = .circular
quitBtn.autoresizingMask = [.minXMargin,.maxYMargin]
quitBtn.title = "Q"
quitBtn.action = #selector(NSApplication.terminate)
window.contentView!.addSubview(quitBtn)
}
func applicationDidFinishLaunching(_ notification: Notification) {
buildMenu()
buildWnd()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
let applicationDelegate = ApplicationDelegate()
// **** main.swift **** //
let application = NSApplication.shared
application.setActivationPolicy(NSApplication.ActivationPolicy.regular)
application.delegate = applicationDelegate
application.activate(ignoringOtherApps:true)
application.run()

How do I render a SwiftUI View that is not at the root hierarchy as a UIImage?

Suppose I have a simple SwiftUI View that is not the ContentView such as this:
struct Test: View {
var body: some View {
VStack {
Text("Test 1")
Text("Test 2")
}
}
}
How can I render this view as a UIImage?
I've looked into solutions such as :
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
But it seems that solutions like that only work on UIView, not a SwiftUI View.
Here is the approach that works for me, as I needed to get image exactly sized as it is when placed alongside others. Hope it would be helpful for some else.
Demo: above divider is SwiftUI rendered, below is image (in border to show size)
Update: re-tested with Xcode 13.4 / iOS 15.5
Test module in project is here
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
UIApplication.shared.windows.first?.rootViewController?.view.addSubview(controller.view)
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
// [!!] Uncomment to clip resulting image
// rendererContext.cgContext.addPath(
// UIBezierPath(roundedRect: bounds, cornerRadius: 20).cgPath)
// rendererContext.cgContext.clip()
// As commented by #MaxIsom below in some cases might be needed
// to make this asynchronously, so uncomment below DispatchQueue
// if you'd same met crash
// DispatchQueue.main.async {
layer.render(in: rendererContext.cgContext)
// }
}
}
}
// TESTING
struct TestableView: View {
var body: some View {
VStack {
Text("Test 1")
Text("Test 2")
}
}
}
struct TestBackgroundRendering: View {
var body: some View {
VStack {
TestableView()
Divider()
Image(uiImage: render())
.border(Color.black)
}
}
private func render() -> UIImage {
TestableView().asImage()
}
}
Solution of Asperi works, but if you need image without white background you have to add this line:
controller.view.backgroundColor = .clear
And your View extension will be:
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
UIApplication.shared.windows.first!.rootViewController?.view.addSubview(controller.view)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
controller.view.backgroundColor = .clear
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}

error removing child node from parent node in spriteKit Swift4

I made a parallax background, in GameScene.sks I added an empty node, added two sprites to an empty node. I have a reset button, when I click on it I need the background to be removed and added to its position (restarted), but when I add in scrollBg.removeAllChildren restart function I error occurs, how do I properly add and remove children from the scene ?
import SpriteKit
import GameplayKit
class GameScene: SKScene {
var player: SKSpriteNode!
var scrollBg: SKNode!
var spawnTimer: CFTimeInterval = 0
let fixedDelta: CFTimeInterval = 1.0/60.0 /* 60 FPS */
let scrollSpeed: CGFloat = 700
var sinceTouch: CFTimeInterval = 0
func resetGameScene() {
scrollBG.removeAllChildren()
player.removeAllChildren()
player.position = CGPoint(x: 590 , y: 690)
pauseButton()
}
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
player = childNode(withName: "player") as? SKSpriteNode
scrollBg = childNode(withName: "scrollBG")!
resetGameScene()
}
func scrollWorld() {
scrollBg.position.y -= scrollSpeed * CGFloat(fixedDelta)
for ground in scrollBg.children as! [SKSpriteNode] {
let groundPosition = scrollBg.convert(ground.position, to:
self)
if groundPosition.y <= -ground.size.width {
let newPosition = CGPoint(x: groundPosition.x, y:
(self.size.width ) + ground.size.width * 2)
ground.position = self.convert(newPosition, to:
scrollBg)
}
}
}
override func update(_ currentTime: TimeInterval) {
sinceTouch+=fixedDelta
spawnTimer+=fixedDelta
scrollWorld()
}
}

Resources