Annotation error in Swift for loop with array and UIView - nsarray

I thought it would be a great learning experiment to take a small Obj-C library and convert it to Swift. Since I eventually want to use this library in an app, I found the Path app menu example here: https://github.com/yourabi/PathMenuExample
I think I've done an okay job of converting and searching for answers as I'm just starting out with Obj-C and Swift. But now I'm stuck with an error I can't find a solution for.
ExpandableNavigation.swift
import Foundation
import UIKit;
class ExpandableNavigation: NSObject {
var mainButton: UIButton;
var menuItems: NSArray;
var radius: CGFloat;
var speed: CGFloat;
var bounce: CGFloat;
var bounceSpeed: CGFloat;
var expanded: Bool;
var transition: Bool;
init(menuItems: NSArray, mainButton: UIButton, radius: CGFloat) {
self.menuItems = menuItems;
self.mainButton = mainButton;
self.radius = radius;
self.speed = 0.15;
self.bounce = 0.225;
self.bounceSpeed = 0.1;
expanded = false;
transition = false;
super.init()
if self.mainButton != nil {
for view: UIView in self.menuItems {
view.center = self.mainButton.center;
}
self.mainButton.addTarget(self, action:"press", forControlEvents: UIControlEvents.TouchUpInside);
}
}
func expand() -> Void {
transition = true;
UIView.animateWithDuration(0.15, animations: {
self.mainButton.transform = CGAffineTransformMakeRotation(45.0 * M_PI/180)
})
for view: UIView in self.menuItems {
var index: Int? = findIndex(self.menuItems, valueToFind: view);
var oneOverCount: Float;
if self.menuItems.count <= 1 {
oneOverCount = 1.0;
} else {
oneOverCount = (1.0 / Float(self.menuItems.count - 1));
}
var indexOverCount: CGFloat = CGFloat(index!) * CGFloat(oneOverCount);
var rad: CGFloat = (1.0 - CGFloat(indexOverCount)) * 90.0 * CGFloat(M_PI) / 180.0;
var rotation: CGAffineTransform = CGAffineTransformMakeRotation(rad);
var x: CGFloat = (self.radius + self.bounce * self.radius) * rotation.a;
var y: CGFloat = (self.radius + self.bounce * self.radius) * rotation.c;
var center: CGPoint = CGPointMake(view.center.x + x, view.center.y + y);
UIView.animateWithDuration(self.speed,
delay: self.speed * indexOverCount,
options: UIViewAnimationOptions.CurveEaseIn,
animations: {
view.center = center;
},
completion: {(finished: Bool) in
UIView.animateWithDuration(self.bounceSpeed,
animations: {
var x: CGFloat = self.bounce * self.radius * rotation.a;
var y: CGFloat = self.bounce * self.radius * rotation.c;
var center: CGPoint = CGPointMake(view.center.x + x, view.center.y + y);
view.center = center;
});
if view == (self.menuItems.endIndex - 1) {
self.expanded = true;
self.transition = false;
}
})
}
}
func collapse() -> Void {
transition = true;
}
#IBAction func press(sender: AnyObject) {
if !self.transition {
if !self.expanded {
self.expand();
} else {
self.collapse();
}
}
}
func findIndex<T: Equatable>(array: T[], valueToFind: T) -> Int? {
for (index, value) in enumerate(array) {
if value == valueToFind {
return index
}
}
return nil
}
}
Error on ExpandableNavigation.swift
Error: Type annotation does not match contextual type 'AnyObject'
This error occurs both in the init() and the expand() methods where it says
for view: UIView in self.menuItems {
I tried making the array UIView[] but that causes errors else where.
ViewController.swift - For reference
class ViewController: UIViewController {
#IBOutlet var expandNavMainButton: UIButton;
#IBOutlet var expandNavCheckInButton: UIButton;
#IBOutlet var expandNavReviewButton: UIButton;
#IBOutlet var expandNavPhotoButton: UIButton;
#IBOutlet var expandNavStatusButton: UIButton;
var expandableNavigation: ExpandableNavigation;
init(coder aDecoder: NSCoder!) {
var buttons: NSArray = [expandNavCheckInButton, expandNavReviewButton, expandNavPhotoButton, expandNavStatusButton];
var myRadius: CGFloat = 120.0
self.expandableNavigation = ExpandableNavigation(menuItems: buttons, mainButton: self.expandNavMainButton, radius: myRadius);
super.init(coder: aDecoder)
}

Swift will not do any implicit casting for you for safety. It will not cast AnyObject (which is the type that NSArray stores) to a UIView without an explicit cast. You should do the following:
for obj : AnyObject in menuItems {
if let view = obj as? UIView {
view.center = self.mainButton.center;
}
}
This loops through the array of menu items with an AnyObject and the checks if obj is a view before casting it as one.

Related

Update #StateObject every time that init() is invoked in SwiftUI

I'm writing a SwiftUI code for a carousel, and I have a CarouselModel class that goes like this:
CarouselModel.swift
import Foundation
import SwiftUI
class CarouselModel: ObservableObject {
//MARK: - Properties
#Published private var imagesCount: Int
#Published private var currentImage: Int
#Published private var offsetMainAxis: CGFloat
private var screenSize: CGFloat
private var isHorizontal: Bool
private var padding: CGFloat
private var nextImage: Int {
return (self.currentImage+1) % self.imagesCount
}
private var prevImage: Int {
return (self.currentImage-1+self.imagesCount) % self.imagesCount
}
private var centralIndex: CGFloat {
return CGFloat(self.imagesCount-1)/2
}
public var computedOffset: CGFloat {
return (centralIndex-CGFloat(currentImage))*(screenSize-padding) + offsetMainAxis
}
init(imagesCount: Int, isHorizontal: Bool = true, padding: CGFloat = 20, currentImage: Int = 0) {
self.imagesCount = imagesCount
self.currentImage = currentImage
self.offsetMainAxis = 0
self.padding = padding
self.isHorizontal = isHorizontal
if isHorizontal {
self.screenSize = UIScreen.main.bounds.width
} else {
self.screenSize = UIScreen.main.bounds.height
}
}
//MARK: - Methods
public func getCurrentImage() -> Int {
return self.currentImage
}
public func goNext() -> Void {
withAnimation(.easeOut(duration: 0.5)) {
currentImage = nextImage
}
}
public func goPrevious() -> Void {
withAnimation(.easeOut(duration: 0.5)) {
currentImage = prevImage
}
}
public func onDragChange(offset: CGFloat) -> Void {
self.offsetMainAxis = offset
}
public func onDragEnd() -> Void {
if abs(offsetMainAxis) > 0.2*screenSize {
if offsetMainAxis > 0 {
withAnimation(.easeOut(duration: 0.5)) {
offsetMainAxis = 0
currentImage = prevImage
}
} else {
withAnimation(.easeOut(duration: 0.5)) {
offsetMainAxis = 0
currentImage = nextImage
}
}
} else {
withAnimation(.easeOut(duration: 0.5)) {
offsetMainAxis = 0
}
}
}
public func skipTo(number i: Int) {
withAnimation(.easeOut(duration: 0.5)) {
currentImage = i
}
}
}
At this point every time I need to place a concrete Carousel in my app I create a CarouselView.swift file containing some View, and the thing goes more or less like this:
CarouselView.swift
import SwiftUI
struct Carousel: View {
#StateObject var carousel: CarouselModel
private var screenWidth: CGFloat = UIScreen.main.bounds.width
private var images: [String]
private let padding: CGFloat
private var descriptions: [String]?
#State private var imageSelected: [String:Bool]
init(images: [String], descriptions: [String]?, padding: CGFloat = 20) {
self.images = images
self.padding = padding
_carousel = StateObject(
wrappedValue:
CarouselModel(
imagesCount: images.count,
isHorizontal: true,
padding: padding
)
)
self.imageSelected = Dictionary(uniqueKeysWithValues: images.map{ ($0, false) })
guard let desc = descriptions else {
self.descriptions = nil
return
}
self.descriptions = desc
}
var body: some View {
// USE carousel TO DISPLAY SOME CAROUSEL IMPLEMENTATION
}
}
Now, a problem arises when inside some container I need to display a different set of images based on a #State private var selectedImgSet: Int and each set has a different amount of pictures in it. The point is, whenever I call CarouselView(...) inside the container and its parameters change, its init is invoked but the CarouselModel doesn't get updated with the new images count.
This is totally expected since it is a #StateObject but I'm not sure how the update should happen. I can't use the .onAppear(perform: ) hook in the CarouselView since that is executed only once, when the CarouselView first appears.
What is the correct way to update the carousel: CarouselModel variable whenever CarouselView's init parameters change?
EDIT: Here are pastes for a full working examples: ContentView, CarouselItem, CarouselModel, CarouselView, Items. Add the following images sets to your assets: item00, item01, item02, item10, item11, item20, item21, item22, item23. Any 1920x1080p placeholder will do the trick for now.
I eventually fixed my issue the following way: I introduced the following method in CarouselModel:
public func update(_ images: Int) {
print("Performing update with \(images) images")
self.imagesCount = images
self.currentImage = 0
self.offsetMainAxis = 0
}
Then I added to the outmost View inside CarouselView the following modifier:
.onChange(of: self.images) { v in
self.carousel.update(v.count)
}
Now the update happens as expected.
You've got things the wrong way round. Create the #StateObject in the View above Carousel View and pass it in as #ObservedObject. However, since your model does no loading or saving it doesn't need to be a reference type, i.e. an object. A value type will suffice, i.e. a struct, so it can be #State in the parent and pass in as #Binding so you can call its mutating functions.

SwiftUI: Pause and Resume animation

The idea is this: an object has a start position (A) and an end position (B). When the start button is pressed, the object moves from Ax, Ay to Bx, By. At an arbitrary point in time, you can stop its movement and continue.
I tried to implement this with a timer that moves the object every n time periods. It works, but the problem is that the timer eats up a lot of memory and when ten objects are created, the application freezes.
I do not ask you to do the task for me, but I will be very grateful if you tell me where to dig
Code currently in use
typealias RemainingDurationProvider<Value: VectorArithmetic> = (Value) -> TimeInterval
typealias AnimationWithDurationProvider = (TimeInterval) -> Animation
extension Animation {
static let instant = Animation.linear(duration: 0.0001)
}
struct PausableAnimationModifier<Value: VectorArithmetic>: AnimatableModifier {
#Binding var binding: Value
#Binding var paused: Bool
private let targetValue: Value
private let remainingDuration: RemainingDurationProvider<Value>
private let animation: AnimationWithDurationProvider
var animatableData: Value
init(binding: Binding<Value>, targetValue: Value, remainingDuration: #escaping RemainingDurationProvider<Value>, animation: #escaping AnimationWithDurationProvider, paused: Binding<Bool>) {
_binding = binding
self.targetValue = targetValue
self.remainingDuration = remainingDuration
self.animation = animation
_paused = paused
animatableData = binding.wrappedValue
}
func body(content: Content) -> some View {
content
.onChange(of: paused) { isPaused in
if isPaused {
withAnimation(.instant) {
binding = animatableData
}
} else {
withAnimation(animation(remainingDuration(animatableData))) {
binding = targetValue
}
}
}
}
}
extension View {
func pausableAnimation<Value: VectorArithmetic>(binding: Binding<Value>, targetValue: Value, remainingDuration: #escaping RemainingDurationProvider<Value>, animation: #escaping AnimationWithDurationProvider, paused: Binding<Bool>) -> some View {
self.modifier(PausableAnimationModifier(binding: binding, targetValue: targetValue, remainingDuration: remainingDuration, animation: animation, paused: paused))
}
}
struct TestView: View {
#State private var isPaused = false
#State private var offsetX: Double = .zero
#State private var startOffsetX: Double = .zero
#State private var endOffsetX: Double = 200.0
#State private var offsetY: Double = .zero
#State private var startPositionY: Double = .zero
#State private var endPositionY: Double = 500.0
private let duration: TimeInterval = 6
private var remainingDurationForX: RemainingDurationProvider<Double> {
{ currentAngle in duration * (1 - (currentAngle - startOffsetX) / (endOffsetX - startOffsetX)) }
}
private var remainingDurationForY: RemainingDurationProvider<Double> {
{ currentAngle in duration * (1 - (currentAngle - startPositionY) / (endPositionY - startPositionY)) }
}
private let animation: AnimationWithDurationProvider = { duration in
.linear(duration: duration)
}
var body: some View {
VStack {
ZStack {
VStack {
HStack {
Rectangle()
.frame(width: 50, height: 50)
.offset(x: offsetX, y: offsetY)
.pausableAnimation(binding: $offsetX,
targetValue: endOffsetX,
remainingDuration: remainingDurationForX,
animation: animation,
paused: $isPaused)
.pausableAnimation(binding: $offsetY,
targetValue: endPositionY,
remainingDuration: remainingDurationForY,
animation: animation,
paused: $isPaused)
Spacer()
}
Spacer()
}
}
HStack {
ControllButton(text: “Start”, action: {
offsetX = startOffsetX
offsetY = startPositionY
withAnimation(animation(duration)) {
offsetX = endOffsetX
offsetY = endPositionY
}
})
ControllButton(text: “NewPosition”, action: {
startOffsetX = endOffsetX
startPositionY = endPositionY
endOffsetX = 300
endPositionY = 30
})
ControllButton(text: isPaused ? “Resume” : “Pause”, action: {
isPaused = !isPaused
})
ControllButton(text: “Stop”, action: {
offsetX = .zero
offsetY = .zero
})
}
.padding(.bottom)
}
}
}
struct ControllButton: View {
var text: String
var action: () -> ()
var body: some View {
Button(text) {
action()
}
.padding()
.background(Color.yellow)
.frame(height: 35)
.cornerRadius(10)
}
}
Not sure the exact intent of your code, but here's something you could try:
struct ContentView: View {
#State var recs: [(AnyView, UUID)] = []
#State var isPaused: Bool = true
#State var shouldUpdate: Bool = false
var body: some View {
VStack {
ZStack {
ForEach(recs, id: \.1) { $0.0 }
}
Spacer()
HStack {
Button(isPaused ? "Start" : "Pause") {
isPaused.toggle()
}
Button("Add") {
recs.append(
(AnyView(
Rectangle()
.frame(width: 50, height: 50)
.pausableAnimation(
startingPosition: .init(x: .random(in: 0..<300), y: 0),
endingPosition: .init(x: .random(in: 0..<300), y: .random(in: 300..<700)),
isPaused: $isPaused,
shoudUpdate: $shouldUpdate
)
), UUID())
)
}
Button("Update") {
shouldUpdate = true
}
Button("Reset") {
isPaused = true
recs.removeAll()
}
}
.buttonStyle(.borderedProminent)
.tint(.orange)
}
}
}
extension CGPoint {
func isCloseTo(_ other: CGPoint) -> Bool {
(self.x - other.x) * (self.x - other.x) + (self.y - other.y) * (self.y - other.y) < 10
}
}
struct PausableAnimation: ViewModifier {
#State var startingPosition: CGPoint
#State var endingPosition: CGPoint
#Binding var isPaused: Bool
#Binding var shouldUpdate: Bool
private let publisher = Timer.TimerPublisher(interval: 0.1, runLoop: .main, mode: .default).autoconnect()
#State private var fired = 0
func body(content: Content) -> some View {
content
.position(startingPosition)
.animation(.linear, value: startingPosition)
.onReceive(publisher) { _ in
if !isPaused && !startingPosition.isCloseTo(endingPosition){
startingPosition.x += (endingPosition.x - startingPosition.x) / CGFloat(20 - fired)
startingPosition.y += (endingPosition.y - startingPosition.y) / CGFloat(20 - fired)
fired += 1
}
}
.onChange(of: shouldUpdate) { newValue in
if newValue == true {
updatePosition()
shouldUpdate = false
}
}
}
func updatePosition() {
endingPosition = .init(x: .random(in: 0..<300), y: .random(in: 0..<700))
fired = 0
}
}
extension View {
func pausableAnimation(startingPosition: CGPoint, endingPosition: CGPoint, isPaused: Binding<Bool>, shoudUpdate: Binding<Bool>) -> some View {
modifier(
PausableAnimation(
startingPosition: startingPosition,
endingPosition: endingPosition,
isPaused: isPaused,
shouldUpdate: shoudUpdate
)
)
}
}
Basically the idea here is in each PausableAnimation, a TimerPublisher is created to fire every 0.1 second. Anytime the publisher fires, it will move from startingPosition and endingPosition by 1/20 of the distance between them.
I used a CGPoint to keep both x and y information in a single variable. However, using two separate variables shouldn't change much of the code beside needing to pass in more data in the initializer.
I wrapped the modified view in a (AnyView, UUID), so I can add more of them into an array and display them through ForEach, dynamically.
Feel free to play around, I hope the idea is clear.

Force a custom gesture to end in SwiftUI

I've implemented a custom gesture via the Gesture protocol and it is mostly working. The one thing I have yet to figure out is how to declare that the gesture has completed.
Here is my current code.
import Foundation
import SwiftUI
import CoreGraphics
struct WiggleGesture: Gesture {
struct WiggleState {
var startTime: Date?
var bounces: Int = 0
var lastLocation = CGPoint.zero
var lastBounceLocation = CGPoint.zero
}
struct WiggleValue {
var bounces: Int
var elapsedTime: TimeInterval?
}
typealias Value = WiggleValue
#GestureState var wiggleState = WiggleState()
let distanceThreshold: CGFloat
let angleThreshold: CGFloat
init(distanceThreshold: CGFloat = 15, angleThreshold: CGFloat = 120) {
self.distanceThreshold = distanceThreshold
self.angleThreshold = angleThreshold
}
var body: AnyGesture<Value> {
AnyGesture (
DragGesture(minimumDistance: distanceThreshold, coordinateSpace: .global)
.updating($wiggleState) { (value, state, _) in
if state.startTime == nil {
state.startTime = Date.now
}
let currentSwipe = value.translation.asVector() - state.lastBounceLocation.asVector() // The current broad motion, from the last bounce location.
let currentTrajectory = value.translation.asVector() - state.lastLocation.asVector() // The current immediate motion, from the last tick.
//TODO: Consider if there should be a minimum translation before counting a bounce?
let trajectoryDelta = abs(currentSwipe.angleTo(other: currentTrajectory))
if abs(trajectoryDelta) > angleThreshold {
state.bounces += 1
state.lastBounceLocation = value.translation.asPoint()
}
state.lastLocation = value.translation.asPoint()
}
.map { _ in
var elapsedTime: TimeInterval?
if let startTime = wiggleState.startTime {
elapsedTime = Date.now.timeIntervalSince(startTime)
}
return WiggleValue(bounces: wiggleState.bounces, elapsedTime: elapsedTime)
}
)
}
}
extension View {
func onWiggle(distanceThreshold: CGFloat = 15, angleThreshold: CGFloat = 120, perform action: #escaping (WiggleGesture.Value) -> Void) -> some View {
gesture(
WiggleGesture(distanceThreshold: distanceThreshold, angleThreshold: angleThreshold)
.onEnded { value in
action(value)
}
)
}
}
Currently, .onEnded is still the one that comes from DragGesture and triggers when the user releases their touch.
Ideally, what I want is to specify a "bounces required" and "timeout" to the gesture so that when it has detected n number of bounces within the timeout it will trigger the .onEnded call and the gesture will stop evaluating.

Building a page that shows a photo like photo app (zoom and pan)

How to build a page that works exactly like the Photo Apps of the iOS that can zoom into a photo using MagnificationGesture() and can pan after zoom using Pure SwiftUI?
I have tried to look for solutions in the forum, yet, none of question has a solution yet. Any advise?
Here is my code:
let magnificationGesture = MagnificationGesture()
.onChanged { amount in
self.currentAmount = amount - 1
}
.onEnded { amount in
self.finalAmount += self.currentAmount
self.currentAmount = 0
}
let tapGesture = TapGesture()
.onEnded {
self.currentAmount = 0
self.finalAmount = 1
}
Image("Cat")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width:UIScreen.main.bounds.width,height: UIScreen.main.bounds.height)
.scaleEffect(finalAmount + currentAmount)
.simultaneousGesture(magnificationGesture)
.simultaneousGesture(tapGesture)
Originally, I tried to add 1 more simultaneousGesture, dragGesture() which adjust the offset, but it fails to work.
The current code zoom the image well, but after zoom in, I want it to be allowed to pan. I have tried to add UIScrollView it also fails.
Here is my thought:
let dragGesture = DragGesture()
.onChanged { value in self.offset = value.translation }
and to add .offset() to the image.
However, it fails to work and the simulator is out of memory.
Any advise?
Use DragGesture() with position.
Tested Xcode 12.1 with iOS 14.2 (No Memory issues.)
struct ContentView: View {
#State private var currentAmount: CGFloat = 0
#State private var finalAmount: CGFloat = 1
#State private var location: CGPoint = CGPoint(x: UIScreen.main.bounds.width/2, y: UIScreen.main.bounds.height/2)
#GestureState private var startLocation: CGPoint? = nil
var body: some View {
let magnificationGesture = MagnificationGesture()
.onChanged { amount in
self.currentAmount = amount - 1
}
.onEnded { amount in
self.finalAmount += self.currentAmount
self.currentAmount = 0
}
// Here is create DragGesture and handel jump when you again start the dragging/
let dragGesture = DragGesture()
.onChanged { value in
var newLocation = startLocation ?? location
newLocation.x += value.translation.width
newLocation.y += value.translation.height
self.location = newLocation
}.updating($startLocation) { (value, startLocation, transaction) in
startLocation = startLocation ?? location
}
return Image("temp_1")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width:UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.scaleEffect(finalAmount + currentAmount)
.position(location)
.gesture(
dragGesture.simultaneously(with: magnificationGesture)
)
}
}
I finally managed to solve the issue with UIViewRepresentable.
struct ImageScrollView: UIViewRepresentable {
private var contentSizeWidth: CGFloat = 0
private var contentSizeHeight: CGFloat = 0
private var imageView = UIImageView()
private var scrollView = UIScrollView()
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: UIViewRepresentableContext<ImageScrollView>) -> UIScrollView {
let image = UIImage(named: "Dummy")
let width = image?.size.width
let height = image?.size.height
imageView.image = image
imageView.frame = CGRect(x: 0, y: 0, width: width ?? 0, height: height ?? 0)
imageView.contentMode = UIView.ContentMode.scaleAspectFit
imageView.isUserInteractionEnabled = true
scrollView.delegate = context.coordinator
scrollView.isScrollEnabled = true
scrollView.clipsToBounds = true
scrollView.bouncesZoom = true
scrollView.isUserInteractionEnabled = true
scrollView.minimumZoomScale = 0.5 //scrollView.frame.size.width / (width ?? 1)
scrollView.maximumZoomScale = 2
scrollView.zoomScale = 1
scrollView.contentSize = imageView.frame.size
scrollView.addSubview(imageView)
let doubleTapGestureRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(sender:)))
doubleTapGestureRecognizer.numberOfTapsRequired = 2;
doubleTapGestureRecognizer.numberOfTouchesRequired=1;
scrollView.addGestureRecognizer(doubleTapGestureRecognizer)
imageView.addGestureRecognizer(doubleTapGestureRecognizer)
return scrollView
}
func updateUIView(_ uiView: UIScrollView,
context: UIViewRepresentableContext<ImageScrollView>) {
}
class Coordinator: NSObject, UIScrollViewDelegate {
var control: ImageScrollView
init(_ control: ImageScrollView) {
self.control = control
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("scrollViewDidScroll")
centerImage()
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView,
with view: UIView?,
atScale scale: CGFloat) {
print("scrollViewDidEndZooming")
print(scale, scrollView.minimumZoomScale, scrollView.maximumZoomScale)
scrollView.setZoomScale(scale, animated: true)
centerImage()
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return self.control.imageView
}
func centerImage() {
let boundSize = self.control.scrollView.frame.size
var frameToCenter = self.control.imageView.frame
frameToCenter.origin.x = 0
frameToCenter.origin.y = 0
if (frameToCenter.size.width<boundSize.width) {
frameToCenter.origin.x = (boundSize.width-frameToCenter.size.width)/2;
}
if (frameToCenter.size.height<boundSize.height) {
frameToCenter.origin.y = (boundSize.height-frameToCenter.size.height)/2;
}
self.control.imageView.frame = frameToCenter
}
#objc func handleTap(sender: UITapGestureRecognizer) {
if (self.control.scrollView.zoomScale==self.control.scrollView.minimumZoomScale) {
self.control.scrollView.zoomScale = self.control.scrollView.maximumZoomScale/2;
} else {
self.control.scrollView.zoomScale = self.control.scrollView.minimumZoomScale;
}
print("tap")
}
}
}

Combine asynchronous return values in SwiftUI

I have 2 asynchronous return values from 2 different classes, one from HealthKit, the other from MotionManager. My goal is to combine these values and output them in a swiftui View, where it refreshes every second. I know I have to look at the combine framework here, but I don't know where to start. I can't find a lot of tutorials which describe Swiftui + Combine. I know I have to look at .combineLatest but do I have to write my own Publisher and Subscriber, or can I use #Published property wrapper I have here (#Published var motionData = MotionData() and #Published var heartRateValue: Double = 0.0) ?
My MotionManager Class:
struct MotionValues {
var rotationX: Double = 0.0
var rotationY: Double = 0.0
var rotationZ: Double = 0.0
var pitch: Double = 0.0
var roll: Double = 0.0
var yaw: Double = 0.0
}
class MotionManager: ObservableObject {
#Published var motionValues = MotionValues()
private let manager = CMMotionManager()
func startMotionUpdates() {
manager.deviceMotionUpdateInterval = 1.0
manager.startDeviceMotionUpdates(to: .main) { (data, error) in
guard let data = data, error == nil else {
print(error!)
return
}
self.motionValues.rotationX = data.rotationRate.x
self.motionValues.rotationY = data.rotationRate.y
self.motionValues.rotationZ = data.rotationRate.z
self.motionValues.pitch = data.attitude.pitch
self.motionValues.roll = data.attitude.roll
self.motionValues.yaw = data.attitude.yaw
}
}
func stopMotionUpdates() {
manager.stopDeviceMotionUpdates()
resetAllMotionData()
}
func resetAllMotionData() {
self.motionValues.rotationX = 0.0
self.motionValues.rotationY = 0.0
self.motionValues.rotationZ = 0.0
self.motionValues.pitch = 0.0
self.motionValues.roll = 0.0
self.motionValues.yaw = 0.0
}
}
My HealthKitManager Class:
class HealthKitManager: ObservableObject {
private var healthStore = HKHealthStore()
private var heartRateQuantity = HKUnit(from: "count/min")
private var activeQueries = [HKQuery]()
#Published var heartRateValue: Double = 0.0
func autorizeHealthKit() {
let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
let HKreadTypes: Set = [heartRate, heartRateVariability]
healthStore.requestAuthorization(toShare: nil, read: HKreadTypes) { (success, error) in
if let error = error {
print("Error requesting health kit authorization: \(error)")
}
}
}
func fetchHeartRateData(quantityTypeIdentifier: HKQuantityTypeIdentifier ) {
let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()])
let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = {
query, samples, deletedObjects, queryAnchor, error in
guard let samples = samples as? [HKQuantitySample] else {
return
}
self.process(samples, type: quantityTypeIdentifier)
}
let query = HKAnchoredObjectQuery(type: HKObjectType.quantityType(forIdentifier: quantityTypeIdentifier)!, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler)
query.updateHandler = updateHandler
healthStore.execute(query)
activeQueries.append(query)
}
private func process(_ samples: [HKQuantitySample], type: HKQuantityTypeIdentifier) {
for sample in samples {
if type == .heartRate {
DispatchQueue.main.async {
self.heartRateValue = sample.quantity.doubleValue(for: self.heartRateQuantity)
}
}
}
}
func stopFetchingHeartRateData() {
activeQueries.forEach { healthStore.stop($0) }
activeQueries.removeAll()
DispatchQueue.main.async {
self.heartRateValue = 0.0
}
}
}
I started with creating a combinedViewModel but I'm stuck here and don't know if this is the way to go:
class CombinedViewModel: ObservableObject {
#Published var motionManager: MotionManager = MotionManager()
#Published var healthManager: HealthKitManager = HealthKitManager()
var anyCancellable: AnyCancellable?
init() {
anyCancellable = Publishers
.CombineLatest(motionManager.$motionValues,healthManager.$heartRateValue)
.sink(receiveValue: {
// Do something
}
})
}
}
Where do I need to focus ? Do I need to learn the combine framework completely to write my own publishers and subscribers, or is there something available with #Published that can do the job ? Or do I need to go for another approach with my CombinedViewModel?
added contentView for reference:
struct ContentView: View {
#State var isActive: Bool = false
private var motion = MotionManager()
private var health = HealthKitManager()
#ObservedObject var combinedViewModel = CombinedViewModel(managerOne: motion, managerTwo: health)
private var motionValues: MotionValues {
return combinedViewModel.combinedValues.0
}
private var heartRateValue: Double {
return combinedViewModel.combinedValues.1
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Indicator(title: "X:", value: motionValues.rotationX)
Indicator(title: "Y:", value: motionValues.rotationY)
Indicator(title: "Z:", value: motionValues.rotationZ)
Divider()
Indicator(title: "Pitch:", value: motionValues.pitch)
Indicator(title: "Roll:", value: motionValues.roll)
Indicator(title: "Yaw:", value: motionValues.yaw)
Divider()
Indicator(title: "HR:", value: heartRateValue)
}
.padding(.horizontal, 10)
Button(action: {
self.isActive.toggle()
self.isActive ? self.start() : self.stop()
}) {
Text(isActive ? "Stop" : "Start")
}
.background(isActive ? Color.green : Color.blue)
.cornerRadius(10)
.padding(.horizontal, 5)
}.onAppear {
self.health.autorizeHealthKit()
}
}
private func start() {
self.motion.startMotionUpdates()
self.health.fetchHeartRateData(quantityTypeIdentifier: .heartRate)
}
private func stop() {
self.motion.stopMotionUpdates()
self.health.stopFetchingHeartRateData()
}
}
You can create a new publisher (I would recommend an AnyPublisher) in your CombinedViewModel that combines the output from both. Here's a simplified version of your code with a CombinedViewModel:
class ManagerOne {
#Published var someValue = "Some Value"
}
class ManagerTwo {
#Published var otherValue = "Other Value"
}
class CombinedViewModel {
var combinedPublisher: AnyPublisher<(String, String), Never>
init(managerOne: ManagerOne, managerTwo: ManagerTwo) {
combinedPublisher = managerOne.$someValue
.combineLatest(managerTwo.$otherValue)
.eraseToAnyPublisher()
}
}
If you need CombinedViewModel to be an observed object you would adapt the code to be more like this:
class CombinedViewModel: ObservableObject {
#Published var combinedValue: (String, String) = ("", "")
var cancellables = Set<AnyCancellable>()
init(managerOne: ManagerOne, managerTwo: ManagerTwo) {
managerOne.$someValue
.combineLatest(managerTwo.$otherValue)
.sink(receiveValue: { [weak self] combined in
self?.combinedValue = combined
})
.store(in: &cancellables)
}
}
A side note about this:
#Published var motionManager: MotionManager = MotionManager()
#Published var healthManager: HealthKitManager = HealthKitManager()
Since both of these managers are classes, $motionManager and $healthManager will only emit values when you assign a new instance of MotionManager or HealthKitManager to them. Not when a property of either manager changes.