Updating rotary knob in real time with SwiftUI - xcode

I'm currently trying to convert a slider in to a rotary knob and having a tough time of it all. The knob works in design but i'm struggling to set the correct value within the knob and as a result change the value within the app in real time.
I'm using AVAudio to set up an engine for people to record with that has effects like Reverb and Delay.
The reverb value is set as followed within the Audio Class:
#Published var reverbValue: Float = 0.0
and later on referenced in a function to change it's value
func changeReverbValue() {
setReverb.wetDryMix = reverbValue
}
When I use a regular slider as follows the change works:
Slider(value: $recordingsettings.reverbValue, in: Float(0.0)...recordingsettings.reverbMaxValue, onEditingChanged: { _ in
self.recordingsettings.changeReverbValue()
}).accentColor(Color.white)
As mentioned the knob works fine in its design:
ZStack {
Knobs(color: .orange)
.rotationEffect(
.degrees(max(0, initialCircleState()))
)
.gesture(DragGesture(minimumDistance: 0)
.onEnded({ _ in
startDragValue = -1.0
})
.onChanged { dragValue in
let touchDifferential = touchDifference(dragValue)
setInitialDragVal()
let computedTouch = computeTouch(touchDifferential)
print(computedTouch)
baseValue = getBaseVal(computedTouch)
let normalizeVal = baseValue / touchAmt
value = Float(normalizeVal * rngOffset(range: bounds) + bounds.lowerBound)
print("vaule is: \(value)")
}
)
GrayCircle(bounds: bounds)
OrangeCircle(baseValue: $value, bounds: bounds)
}
.rotationEffect(bounds.lowerBound < 0 ? .degrees(90) : .degrees(107))
I've had some success connecting the knob to the reverb value to the point where the slider also moves when the rotary knob does, however the changeReverbValue function doesn't work.
The success comes from setting the value within the knob view as follows:
#Binding var value: AUValue
And then referencing the knob on the same struct of the main view as the slider:
Knob(value: $recordingsettings.reverbValue, bounds: 0...CGFloat(recordingsettings.reverbMaxValue))
.onTapGesture {
self.recordingsettings.changeReverbValue()
}
The on tap gesture was a way in which I thought it might call the change reverb value function when the knob was turned but to no avail.
The binding value passed in the knob also has other challenges. For some reason when I playback audio without headphones and then turn the knob the audio starts to stutter. This doesn't happen with headphones and I find that pretty weird.
Anyone know how I could reference the reverb value within the rotary knob and have the changeReverbValue function called at the same time?
I just want to replace the slider with something that looks better. Otherwise i'm going to have to leave this for a bit and just implement the sliders instead throughout the app.
If I don't set the value of the knob as #binding in the rotary knob view the track doesn't stutter on playback but then I don't know if it's possible to change the reverb value without a #binding var.

I struggled to parse a precise singular problem statement from the narrative, so this is perhaps just an off-base commentary and not a solution. I walked away thinking your problem is: a custom UI component is "jumpy/stuttery" during interaction and produces similarly punctate effects on app state.
If that's fair, I worked around the same issue in my first SwiftUI app. The cause could be two things:
Not using the right async queue by accident.
Forcing an #State or #Published property to update for all global state changes. That means you are pushing stale state from earlier back into an interaction, possibly with a circular feedback loop.
The solution is pretty simple. Request and consume model updates with both a value and a source tag. Throttle and filter out the self-tag to keep local state responsive to only one just-in-time data stream.
I used that pattern in that first app (free, Inclusivity for Mac) to coordinate an HSV color wheel and color channel custom slider components. The wheel, sliders, and other interactions feed/read a shared Combine pipeline (CurrentValueSubject<SourcedColorVector,Never>.erasedToAny()).
Some sample gestures, which simply punt the gating work to a view model:
The HSVWheel drag-around or click gesture
private func touchUpInWheel() -> ExclusiveGesture<_ChangedGesture<DragGesture>, _EndedGesture<DragGesture>> {
ExclusiveGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .named(wheel))
.onChanged { change in
let adjusted = CGPoint(x: change.translation.width - targetDiameter + change.startLocation.x / 2,
y: change.translation.height - targetDiameter + change.startLocation.y / 2)
vm.setHueSat(drag: adjusted)
},
DragGesture(minimumDistance: 0, coordinateSpace: .named(wheel))
.onEnded { end in
let click = CGPoint(x: end.location.x - vm.radius,
y: end.location.y - vm.radius)
vm.setHueSat(click: click)
}
)
}
A typical slider gesture (this is the vertical value slider)
private func tapAndDrag() -> _EndedGesture<_ChangedGesture<DragGesture>> {
DragGesture(minimumDistance: 0,
coordinateSpace: .named(valuePickerSpace))
.onChanged { value in
let location = value.location.y - .valueSliderGestureOffset
vm.setValueKnobLocation(raw: location)
}
.onEnded { end in
let location = end.location.y - .valueSliderGestureOffset
vm.setValueKnobLocation(raw: location)
}
}

Related

Autolayout: conditional content compression resistance priority using NSUserInterfaceCompression

I've created a custom segment control (Cocoa / macOS) by subclassing NSView (does not use any existing controls / buttons; it's an entirely custom view with a complex set of internal constraints) that has two modes:
Displays all segments horizontally by default: [ segment 1 ] [ segment 2 ] [ segment 3 ]
Displays a single segment as a drop down when all segments cannot fit in the window / current set of constraints (influenced by surrounding controls and their constraints): [ segment 1 🔽 ]
This works just fine, and I'm able to switch between / animate the two modes at runtime. However what I want to ultimately achieve is automatic expansion / compression based on the current window size (or switch between the two when the user is resizing the window). I want this control to be reusable without the window / view controller managing the switch, and trying to avoid switching between constraints based on 'rough' estimates from inside of a superview's layout call (which feels like a hack).
It seems NSSegmentControl, NSButton etc implement NSUserInterfaceCompression which should do what I am trying to achieve, however none of the methods in that protocol get called at any time during initial layout / intrinsic content size refresh / window resize etc. I also find the documentation lacking; the only useful information I found was inside the NSSegmentControl header files. The protocol seems to be exactly what I need - for the system to call the appropriate methods to determine a minimum / ideal size and ask the control to resize itself when space is at a premium.
For what it's worth, I've tried subclassing NSButton too (for various reasons, I need to stick to subclassing NSView) - however that did not trigger any of these methods either (i.e. from NSUserInterfaceCompression).
Any idea what I'm missing?
Curious... a little searching, and I can find very little information about NSUserInterfaceCompression?
Not sure what all you need to do, but something along the lines of this approach might work for you:
class SegTestView: NSView {
let segCtrl = NSSegmentedControl()
var curWidth: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
addSubview(segCtrl)
segCtrl.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
segCtrl.topAnchor.constraint(equalTo: topAnchor),
segCtrl.leadingAnchor.constraint(equalTo: leadingAnchor),
segCtrl.trailingAnchor.constraint(equalTo: trailingAnchor),
segCtrl.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func layout() {
super.layout()
// only execute if bounds.width has changed
if curWidth != bounds.width {
curWidth = bounds.width
segCtrl.segmentCount = 3
segCtrl.setLabel("First", forSegment: 0)
segCtrl.setLabel("Second", forSegment: 1)
segCtrl.setLabel("Third", forSegment: 2)
if segCtrl.intrinsicContentSize.width > bounds.size.width {
segCtrl.segmentCount = 1
segCtrl.setLabel("Single 🔽", forSegment: 0)
} else {
// in case you want to do something else here...
}
}
}
}
It seems NSUserInterfaceCompression is a dead end. For now I've reported this as feedback / bug regarding the inadequate documentation (FB9062854).
The way I ended up solving this is by:
Setting the following content compression on the custom control:
let priorityToResistCompression = contentCompressionResistancePriority(for: .horizontal)
setContentCompressionResistancePriority(priorityToResistCompression, for: .horizontal)
The last segment (inner NSView subviews) within the control has a trailing anchor set with priority defaultLow to allow it to break so that the control can continue to stretch
Override setFrameSize and determine the best mode to display (compressed, single segment as a drop down, or all the segments if they can fit horizontally). Then call invalidateIntrinsicContentSize() for the content size to be recomputed.
Using the mode determined in the previous step, override intrinsicContentSize and offer the correct size (the minimum compressed version or the one where all segments can fit).
This way the control wraps all of this functionality into a single NSView subclass and relieves any superview / window hosting this control of setting the correct size as the window is resized.

NSAppearance is not updating when toggling dark mode

I have a macOS app that runs only in the macOS status bar. I changed the "Application is agent (UIElement)" property in the Info.plist to "YES":
<key>LSUIElement</key>
<true/>
I have a timer that prints out the appearance's name every 5 seconds like this:
Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
let appearance = NSAppearance.currentDrawing()
print(appearance.name)
}
Problem
The name doesn't actually change when I toggle dark/light mode in system settings. It always prints the name of the appearance that was set when the application launched.
Is there a way to listen to system appearance changes?
Goal
My end goal is actually to draw an NSAttributedString to an NSImage, and use that NSImage as the NSStatusItem button's image.
let image: NSImage = // generate image
statusItem.button?.image = image
For the text in the attributed string I use UIColor.labelColor that is supposed to be based on the system appearance. However it seems to not respect the system appearance change.
When I start the application in Dark Mode and then switch to Light Mode:
When I start the application in Light Mode and then switch to Dark Mode:
Side note
The reason why I turn the NSAttributedString into an NSImage and don't use the NSAttributedString directly on the NSStatusItem button's attributedTitle is because it doesn't position correctly in the status bar.
The problem with drawing a NSAttributedString is, that NSAttributedString doesn't know how to render dynamic colors such as NSColor.labelColor. Thus, it doesn't react on appearance changes. You have to use a UI element.
Solution
I solved this problem by passing the NSAttributedString to a NSTextField and draw that into an NSImage. Works perfectly fine.
func updateStatusItemImage() {
// Use UI element: `NSTextField`
let attributedString: NSAttributedString = ...
let textField = NSTextField(labelWithAttributedString: attributedString)
textField.sizeToFit()
// Draw the `NSTextField` into an `NSImage`
let size = textField.frame.size
let image = NSImage(size: size)
image.lockFocus()
textField.draw(textField.bounds)
image.unlockFocus()
// Assign the drawn image to the button of the `NSStatusItem`
statusItem.button?.image = image
}
React on NSAppearance changes
In addition, since NSImage doesn't know about NSAppearance either I need to trigger a redraw on appearance changes by observing the effectiveAppearance property of the button of the NSStatusItem:
observation = statusItem.observe(\.button?.effectiveAppearance, options: []) { [weak self] _, _ in
// Redraw
self?.updateStatusItemImage()
}

Set position not updated when open another tab

I do a growth animation of a fixed number of items, and after growth, i moved it to left.
Make growth by apply matrix
var trans_vector = new THREE.Matrix4().makeTranslation(0, height / 2, 0);
var graphics = new Graphics();
var rectangle = graphics.box(width, height, material);
rectangle.geometry.applyMatrix(trans_vector);
When a new item is added, i remove one from the container that will be added to scene
var children = this.container.children;
if (this.current_number === this.max_number) {
this.container.remove(children[0]);
this.current_number = this.max_number - 1;
}
object.position.copy(this.position); // this is a fixed position
this.container.add(object);
this.current_number++;
I write a function to translate to left, using tweenjs (sole)
animateTranslation : function(object, padding, duration) {
var new_x = object.position.x - padding; // Move to left
console.log(new_x); // Duplicated item here :(
new TWEEN.Tween(object.position).to({
x : new_x
}, duration).start();
},
And I remove all the "previous" items, using for loop
for (var i = 0; i < this.current_number-1; i++) {
this.animateTranslation(this.container.children[i],
this.padding,
this.duration/4)
}
The above code run correctly if we open and keep this current tab.
The problem is when we move to another tab, do something and then move back, some objects have the same position, that cause the translation to the same position. It looks weird.
It appears on both Chrome, Firefox and IE11.
I dont know why, please point me out what happened.
Most modern browsers choose to limit the delay in setInterval and pause requestAnimationFrame in inactive tabs. This is done in order to preserve CPU cycles and to reduce power consumption, especially important for mobile devices. You can find more details about each browser in this answer
That means, while the tab is inactive, your tween animation is not working the way it is normally expected to.
A simple solution would be to halt the main animation loop if the tab is inactive using a variable.
window.onfocus = function () {
isActive = true;
};
window.onblur = function () {
isActive = false;
};

UICollectionView effective drag and drop

I am currently trying to implement the UITableView reordering behavior using UICollectionView.
Let's call a UItableView TV and a UICollectionView CV (to clarify the following explanation)
I am basically trying to reproduce the drag&drop of the TV, but I am not using the edit mode, the cell is ready to be moved as soon as the long press gesture is triggered. It works prefectly, I am using the move method of the CV, everything is fine.
I update the contentOffset property of the CV to handle the scroll when the user is dragging a cell. When a user goes to a particular rect at the top and the bottom, I update the contentOffset and the CV scroll. The problem is when the user stop moving it's finger, the gesture doesn't send any update which makes the scroll stop and start again as soon as the user moves his finger.
This behavior is definitely not natural, I would prefer continu to scroll until the user release the CV as it is the case in the TV. The TV drag&drop experience is awesome and I really want to reproduce the same feeling. Does anyone know how they manage the scroll in TV during reordering ?
I tried using a timer to trigger a scroll action repeatedly as long as the gesture position is in the right spot, the scroll was awful and not very productive (very slow and jumpy).
I also tried using GCD to listen the gesture position in another thread but the result is even worst.
I ran out of idea about that, so if someone has the answer I would marry him!
Here is the implementation of the longPress method:
- (void)handleLongPress:(UILongPressGestureRecognizer *)sender
{
ReorganizableCVCLayout *layout = (ReorganizableCVCLayout *)self.collectionView.collectionViewLayout;
CGPoint gesturePosition = [sender locationInView:self.collectionView];
NSIndexPath *selectedIndexPath = [self.collectionView indexPathForItemAtPoint:gesturePosition];
if (sender.state == UIGestureRecognizerStateBegan)
{
layout.selectedItem = selectedIndexPath;
layout.gesturePoint = gesturePosition; // Setting gesturePoint invalidate layout
}
else if (sender.state == UIGestureRecognizerStateChanged)
{
layout.gesturePoint = gesturePosition; // Setting gesturePoint invalidate layout
[self swapCellAtPoint:gesturePosition];
[self manageScrollWithReferencePoint:gesturePosition];
}
else
{
[self.collectionView performBatchUpdates:^
{
layout.selectedItem = nil;
layout.gesturePoint = CGPointZero; // Setting gesturePoint invalidate layout
} completion:^(BOOL completion){[self.collectionView reloadData];}];
}
}
To make the CV scroll, I am using that method:
- (void)manageScrollWithReferencePoint:(CGPoint)gesturePoint
{
ReorganizableCVCLayout *layout = (ReorganizableCVCLayout *)self.collectionView.collectionViewLayout;
CGFloat topScrollLimit = self.collectionView.contentOffset.y+layout.itemSize.height/2+SCROLL_BORDER;
CGFloat bottomScrollLimit = self.collectionView.contentOffset.y+self.collectionView.frame.size.height-layout.itemSize.height/2-SCROLL_BORDER;
CGPoint contentOffset = self.collectionView.contentOffset;
if (gesturePoint.y < topScrollLimit && gesturePoint.y - layout.itemSize.height/2 - SCROLL_BORDER > 0)
contentOffset.y -= SCROLL_STEP;
else if (gesturePoint.y > bottomScrollLimit &&
gesturePoint.y + layout.itemSize.height/2 + SCROLL_BORDER < self.collectionView.contentSize.height)
contentOffset.y += SCROLL_STEP;
[self.collectionView setContentOffset:contentOffset];
}
This might help
https://github.com/lxcid/LXReorderableCollectionViewFlowLayout
This is extends the UICollectionView to allow each of the UICollectionViewCells to be rearranged manually by the user with a long touch (aka touch-and-hold). The user can drag the Cell to any other position in the collection and the other cells will reorder automatically. Thanks go to lxcid for this.
Here is an alternative:
The differences between DraggableCollectionView and LXReorderableCollectionViewFlowLayout are:
The data source is only changed once. This means that while the user is dragging an item the cells are re-positioned without modifying the data source.
It's written in such a way that makes it possible to use with custom layouts.
It uses a CADisplayLink for smooth scrolling and animation.
Animations are canceled less frequently while dragging. It feels more "natural".
The protocol extends UICollectionViewDataSource with methods similar to UITableViewDataSource.
It's a work in progress. Multiple sections are now supported.
To use it with a custom layout see DraggableCollectionViewFlowLayout. Most of the logic exists in LSCollectionViewLayoutHelper. There is also an example in CircleLayoutDemo showing how to make Apple's CircleLayout example from WWDC 2012 work.
As of iOS 9, UICollectionView now supports reordering.
For UICollectionViewControllers, just override collectionView(collectionView: UICollectionView, moveItemAtIndexPath sourceIndexPath: NSIndexPath, toIndexPath destinationIndexPath: NSIndexPath)
For UICollectionViews, you'll have to handle the gestures yourself in addition to implementing the UICollectionViewDataSource method above.
Here's the code from the source:
private var longPressGesture: UILongPressGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
longPressGesture = UILongPressGestureRecognizer(target: self, action: "handleLongGesture:")
self.collectionView.addGestureRecognizer(longPressGesture)
}
func handleLongGesture(gesture: UILongPressGestureRecognizer) {
switch(gesture.state) {
case UIGestureRecognizerState.Began:
guard let selectedIndexPath = self.collectionView.indexPathForItemAtPoint(gesture.locationInView(self.collectionView)) else {
break
}
collectionView.beginInteractiveMovementForItemAtIndexPath(selectedIndexPath)
case UIGestureRecognizerState.Changed:
collectionView.updateInteractiveMovementTargetPosition(gesture.locationInView(gesture.view!))
case UIGestureRecognizerState.Ended:
collectionView.endInteractiveMovement()
default:
collectionView.cancelInteractiveMovement()
}
}
Sources:
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UICollectionView_class/#//apple_ref/doc/uid/TP40012177-CH1-SW67
http://nshint.io/blog/2015/07/16/uicollectionviews-now-have-easy-reordering/
If you want to experiment rolling out your own, I just wrote a Swift based tutorial you can look. I tried to build the most basic of cases so as to be easier to follow this.
Here is another approach:
Key difference is that this solution does not require a "ghost" or "dummy" cell to provide the drag and drop functionality. It simply uses the cell itself. Animations are in line with UITableView. It works by adjusting the collection view layout's private datasource while moving around. Once you let go, it will tell your controller that you can commit the change to your own datasource.
I believe it's a bit simpler to work with for most use cases. Still a work in progress, but yet another way to accomplish this. Most should find this pretty easy to incorporate into their own custom UICollectionViewLayouts.

Animate Button size, then revert to null

I am trying to create an animation to make it look like a button turns over and the back shows. So what I was trying to do is:
1- Show a button with BackgroundColor x. (The button now has a Width of null, the property ActualWidth does have a value.)
2- Create a double animation that changes the width of the button to zero.
DoubleAnimation widthAnimation = new DoubleAnimation();
widthAnimation.From = this.ActualWidth;
widthAnimation.To = 0;
widthAnimation.SpeedRatio = 3;
widthAnimation.Duration = TimeSpan.FromMilliseconds(800);
3- Change the BackgroundColor of the button.
ColorAnimation colorAnimation = new ColorAnimation();
colorAnimation.From = State ? _xColor : _yColor;
colorAnimation.To = State ? _yColor : _xColor;
colorAnimation.BeginTime = TimeSpan.FromMilliseconds(400);
colorAnimation.Duration = TimeSpan.Zero;
4- Change the width back to it's original value.
widthAnimation.AutoReverse = true;
The problem is when the animation runs twice the animation reads this.ActualWidth while animating, which causes it to fail to the original width. How can I solve this? I would like to set the Width back to null again, but it seems impossible to me.
You'd better use xaml style and template to "declare" what you want and let WPF/Silverlight take care of all.
If you try to do the same thing by code you can do it but you need to know what the framework does behind the scenes.
Basically you can set
- Style to define the values of some properties of the control
- DataTemplate to define the visual representation of the control's content
- ControlTemplate to define the appearance of the control
Each of those can have Triggers
- Property Triggers
to set properties or starts actions, such as an animation
when a property value changes or when an event is raised;
EventTriggers and Storyboards
to start a set of actions based on the occurrence of an event
If you like to learn about XAML Style and Template,
take a look at http://msdn.microsoft.com/en-us/library/ms745683.aspx
Spend a day to learn and save many hours (or days) of try and error and frustration!
To go right to the point, in your case I think you should use a Storyboard.
See http://msdn.microsoft.com/en-us/library/ms742868.aspx
where you can find also the code equivalent of XAML examples
I came to the idea to targetting the MaxWidth instead of the actual Width. I now use a KeyFrameCollection which sets the MaxWidth to int.MaxValue at the start (so also at the end when using autoreverse).
It will work fine untill there will be phones with a resolution bigger than the max int value.
The code:
DoubleAnimationUsingKeyFrames widthAnimation = new DoubleAnimationUsingKeyFrames();
widthAnimation.KeyFrames.Add(new DiscreteDoubleKeyFrame()
{
KeyTime = TimeSpan.Zero,
Value = int.MaxValue,
});
widthAnimation.KeyFrames.Add(new LinearDoubleKeyFrame()
{
KeyTime = TimeSpan.FromMilliseconds(1),
Value = ActualWidth,
});
widthAnimation.KeyFrames.Add(new LinearDoubleKeyFrame()
{
KeyTime = TimeSpan.FromMilliseconds(400),
Value = 0,
});
widthAnimation.Duration = TimeSpan.FromMilliseconds(400);
widthAnimation.AutoReverse = true;

Resources