I'm trying to animate a transformation within a cell of a grid view, but I don't want this animation to affect the layout of the cells as they are repositioned by data changes. I feel like this should be simple to do, but it doesn't work no matter what I try.
Perhaps someone can demonstrate SwiftUI where cells in a grid view can reorder themselves without a spring animation while the cells use a spring animation to resize locally? That would be greatly appreciated!
var body: some View {
LazyVGrid(columns) {
ForEach(dataSource.dataList) { // This is an array of structs
CellView(data: $0)
}
}
.animation(.default)
}
struct CellView: View {
let data: CellData
#State var contentScale: CGFloat = 1
init(data: CellData) {
self.data = data
let anim = Animation.interpolatingSpring(stiffness: 50, damping: 5)
withAnimation(anim) {
self.contentScale = calcScaleFrom(data: data)
}
}
var body: some View {
// Configure cell with data
// and animate scale based on data properties
}
}
Ok, I didn't find the answer to my exact question... but I did find a workaround. My precise aim was making my cells update their content with a spring animation, but making the grid reorder the cell with the default animation. To do this I simply added a withAnimation block in my grid's data model code which selects the style of animation based on whether the data list changed order!
#Published var currentList: [CellData]
func updateData() {
let newList = ...
let anim: Animation
if newList.map({$0.id}).elementsEqual(currentList.map({$0.id})) {
// Data may have changed but does not require any cell reordering
anim = Animation.interpolatingSpring(stiffness: 80, damping: 5)
} else {
// Cells will change order!
anim = .default
}
withAnimation(anim) {
currentList = newList
}
}
Related
I have a ZStack that I set the color to black and then add a VideoPlayer. When I rotate the device there are still flashes of white around the player. I have played with all sorts of ideas and background colors, foreground colors, opacity and nothing has worked. I just want the background to be black so it looks like a smooth rotation. Anybody have any suggestions or fixes? Here's my code:
import Foundation
import SwiftUI
import AVKit
struct VideoDetail: View {
var videoIDString: String
var videoThumbURL: String
#State var player = AVPlayer()
var body: some View {
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
let videoURL: String = videoIDString
VideoPlayer(player: player)
//.frame(height: 200)
.edgesIgnoringSafeArea(.all)
.onAppear {
player = AVPlayer(url: URL(string: videoURL)!)
player.play()
}
.onDisappear {
player.pause()
}
}
.navigationBarHidden(true)
.background(Color.black.edgesIgnoringSafeArea(.all))
}
}
I was having the same issue and came across a solution: you can set the background color of the hosting window's root view controller's view. You don't have direct access to this within SwiftUI, so in order to do this you can use a method described in this answer.
Just copy the withHostingWindow View extension including HostingWindowFinder somewhere and use the following code in your view to set the background color to black:
var body: some View {
ZStack {
// ...
}
.withHostingWindow { window in
window?.rootViewController?.view.backgroundColor = UIColor.black
}
}
After this, the white corners when rotating should be gone!
Just add
ZStack{
...
}.preferredColorScheme(ColorScheme.dark)
I feel your pain. This is a SwiftUI bug. The way that SwiftUI currently works is that it contains your view tree within a UIKit view. For the most part SwiftUI and UIKit cooperate with one another pretty well, but one particular area that struggles seems to be synchronising UIKit and SwiftUI animations.
Therefore, when the device rotates, it's actually UIKit driving the animation, so SwiftUI has to make a best guess of where it might be on the animation curve but its guess is pretty poor.
The best thing we can do right now is file feedback. Duplicated bug reports are how Apple prioritise what to work on, so the more bug reports from everyone the better. It doesn't have to be long. Title it something like 'SwiftUI animation artefacts on device rotation', and write 'Duplicate of FB10376122' for the description to reference an existing report on the same topic.
Anyway, in the meantime, we can at least grab the UIKit view of the enclosing window and set the background colour on there instead. This workaround is limited as 1) it doesn't change the apparent 'jumpiness' of the above mentioned synchronisation between the UIKit and SwiftUI animations, and 2) will only help if your background is a block colour.
That said, here's a WindowGroup replacement and view modifier pair that ties together this workaround to play as nicely as possible with the rest of SwiftUI.
Example usage:
import SwiftUI
#main
struct MyApp: App {
var body: some Scene {
// Should be at the very top of your view tree within your `App` conforming type
StyledWindowGroup {
ContentView()
// view modifier can be anywhere in your view tree
.preferredWindowColor(.black)
}
}
}
To use, copy the contents below into a file named StyledWindowGroup.swift and add to your project:
import SwiftUI
/// Wraps a regular `WindowGroup` and enables use of the `preferredWindowColor(_ color: Color)` view modifier
/// from anywhere within its contained view tree. Use in place of a regular `WindowGroup`
public struct StyledWindowGroup<Content: View>: Scene {
#ViewBuilder let content: () -> Content
public init(content: #escaping () -> Content) {
self.content = content
}
public var body: some Scene {
WindowGroup {
content()
.backgroundPreferenceValue(PreferredWindowColorKey.self) { color in
WindowProxyHostView(backgroundColor: color)
}
}
}
}
// MARK: - Convenience View Modifer
extension View {
/// Sets the background color of the hosting window.
/// - Note: Requires calling view is contained within a `StyledWindowGroup` scene
public func preferredWindowColor(_ color: Color) -> some View {
preference(key: PreferredWindowColorKey.self, value: color)
}
}
// MARK: - Preference Key
fileprivate struct PreferredWindowColorKey: PreferenceKey {
static let defaultValue = Color.white
static func reduce(value: inout Color, nextValue: () -> Color) { }
}
// MARK: - Window Proxy View Pair
fileprivate struct WindowProxyHostView: UIViewRepresentable {
let backgroundColor: Color
func makeUIView(context: Context) -> WindowProxyView {
let view = WindowProxyView(frame: .zero)
view.isHidden = true
return view
}
func updateUIView(_ view: WindowProxyView, context: Context) {
view.rootViewBackgroundColor = backgroundColor
}
}
fileprivate final class WindowProxyView: UIView {
var rootViewBackgroundColor = Color.white {
didSet { updateRootViewColor(on: window) }
}
override func willMove(toWindow newWindow: UIWindow?) {
updateRootViewColor(on: newWindow)
}
private func updateRootViewColor(on window: UIWindow?) {
guard let rootViewController = window?.rootViewController else { return }
rootViewController.view.backgroundColor = UIColor(rootViewBackgroundColor)
}
}
I'm trying to animate the height constraint of a UITextView inside a UIScrollView. When the user taps the "toggle" button, the text should appear in animation from top to bottom. But somehow UIKit fades in the complete view.
To ensure the "dynamic" height depending on the intrinsic content-size, I deactivate the height constraint set to zero.
#IBAction func toggle() {
layoutIfNeeded()
UIView.animate(withDuration: 0.6, animations: { [weak self] in
guard let self = self else {
return
}
if self.expanded {
NSLayoutConstraint.activate([self.height].compactMap { $0 })
} else {
NSLayoutConstraint.deactivate([self.height].compactMap { $0 })
}
self.layoutIfNeeded()
})
expanded.toggle()
}
The full code of this example is available on my GitHub Repo: ScrollAnimationExample
Reviewing your GitHub repo...
The issue is due to the view that is animated. You want to run .animate() on the "top-most" view in the hierarchy.
To do this, you can either create a new property of your ExpandableView, such as:
var topMostView: UIView?
and then set that property from your view controller, or...
To keep your class encapsulated, let it find the top-most view. Replace your toggle() func with:
#IBAction func toggle() {
// we need to run .animate() on the "top" superview
// make sure we have a superview
guard self.superview != nil else {
return
}
// find the top-most superview
var mv: UIView = self
while let s = mv.superview {
mv = s
}
// UITextView has subviews, one of which is a _UITextContainerView,
// which also has a _UITextCanvasView subview.
// If scrolling is disabled, and the TextView's height is animated to Zero,
// the CanvasView's height is instantly set to Zero -- so it disappears instead of animating.
// So, when the view is "expanded" we need to first enable scrolling,
// and then animate the height (to Zero)
// When the view is NOT expanded, we first disable scrolling
// and then animate the height (to its intrinsic content height)
if expanded {
textView.isScrollEnabled = true
NSLayoutConstraint.activate([height].compactMap { $0 })
} else {
textView.isScrollEnabled = false
NSLayoutConstraint.deactivate([height].compactMap { $0 })
}
UIView.animate(withDuration: 0.6, animations: {
mv.layoutIfNeeded()
})
expanded.toggle()
}
I have a view with tabs on the bottom, one of the views has subviews, to separate the logic visually, I put the tabs of the subview at the top of the view with the following code and it works perfectly:
self.tabbar.frame = CGRect( x: 0,
y: view.safeAreaInsets.top,
width: self.view.frame.size.width,
height: 50)
How do I do this in SwiftUI?
In order to do this you could create your tabs view as a container of the individual tabs something like this...
struct TabbedView: View {
#State private var selectedTab: Int = 0
var body: some View {
VStack {
Picker("", selection: $selectedTab) {
Text("First").tag(0)
Text("Second").tag(1)
Text("Third").tag(2)
}
.pickerStyle(SegmentedPickerStyle())
switch(selectedTab) {
case 0: FirstTabView()
case 1: SecondTabView()
case 2: ThirdTabView()
}
}
}
}
Doing this, you are conditionally populating the "Tab page" based on the value of the segmented control.
By using #State and $selectedTab the segmented control will update the selectedTab value and then re-render the view which will replace the page based on the new value of selectedTab.
Edit
Switches now work in SwiftUI beta. 👍🏻
I have a div, #scrollable, with a scrollbar and I want to scroll to the end.
I can do it by setting the div's "scrollTop" property to the value of the div's "scrollHeight" property thus:
var scrollable = d3.select("#scrollable");
scrollable.property("scrollTop", scrollable.property("scrollHeight"));
but I can't figure out how, if at all, I can tween it.
var scrollheight = scrollable.property("scrollHeight");
d3.select("#scrollable").transition().property("scrollTop", scrollheight);
doesn't work.
Thanks for any ideas.
Regards
You can use a custom d3 tween to transition arbitrary properties like scrollTop.
mbostock's Smooth Scrolling example demonstrates using a custom tween to scroll the entire document.
And here is the example adapted to your context (also viewable interactively on bl.ocks.org):
var scrollable = d3.select("#scrollable");
d3.select("#down").on('click', function() {
var scrollheight = scrollable.property("scrollHeight");
d3.select("#scrollable").transition().duration(3000)
.tween("uniquetweenname", scrollTopTween(scrollheight));
});
function scrollTopTween(scrollTop) {
return function() {
var i = d3.interpolateNumber(this.scrollTop, scrollTop);
return function(t) { this.scrollTop = i(t); };
};
}
I want to make a UIPanRecognizer in a UIStackView. The UI looks like this.
There's a big square which is a StackView which contains 9 labels.
My goal is when I start to drag my finger within the StackView and if a label contains that touchpoint then its color should be blue and on and on. If I lift my finger all of the labels background color turn back to white.
The labels are in a OutletCollection.
Here's my code:
#IBOutlet var testLabels: [UILabel]!
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
var touchedPoint: CGPoint!
if let view = recognizer.view{
if recognizer.state == .began || recognizer.state == .changed {
touchedPoint = recognizer.location(in: view)
for index in testLabels.indices {
if testLabels[index].frame.contains(touchedPoint) {
testLabels[index].backgroundColor = UIColor.blue
}
}
}
if recognizer.state == .ended {
for index in testLabels.indices {
testLabels[index].backgroundColor = UIColor.white
}
}
}
Right now when I touch for example the top-left square and start to drag my finger to the right the whole first column turns blue then the second then the third. That happens when I touch any labels of the first row, but nothing happens when I touch the other squares.
First time, I tried to solve my problem by handle the squares just as an OutletCollection (without the StackView) and the recognizer just worked fine! The reason why I experiment with the StackView that it's so easier to make the layout(just a few constraints needed, without the StackView it is a nightmare).
I'd appreciate any help. Thank you.
Edit: probably this one helps?
Access nested stack views
Each label is a subview of its containing stack view (which is also probably a subview of a stack view), and each label has its own coordinate space.
Assuming your UIPanGestureRecognizer is assigned to the view controller's view, you need to translate the coordinates / frames.
This should work for you:
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
var touchedPoint: CGPoint!
if let view = recognizer.view{
if recognizer.state == .began || recognizer.state == .changed {
touchedPoint = recognizer.location(in: view)
for index in testLabels.indices {
// translate / convert the label's bounds from its frame to the view's coordinate space
let testFrame = view.convert(testLabels[index].bounds, from:testLabels[index] )
// check the resulting testFrame for the point
if testFrame.contains(touchedPoint) {
testLabels[index].backgroundColor = UIColor.blue
}
}
}
else if recognizer.state == .ended {
for index in testLabels.indices {
testLabels[index].backgroundColor = UIColor.white
}
}
}
}
You can also simplify your code a bit by using for object in collection instead of indices. This is functionally the same as above, but maybe a little simpler:
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
var touchedPoint: CGPoint!
if let view = recognizer.view{
if recognizer.state == .began || recognizer.state == .changed {
touchedPoint = recognizer.location(in: view)
for lbl in testLabels {
// translate / convert the label's bounds from its frame to the view's coordinate space
let testFrame = view.convert(lbl.bounds, from:lbl )
// check the resulting testFrame for the point
if testFrame.contains(touchedPoint) {
lbl.backgroundColor = .blue
}
}
}
else if recognizer.state == .ended {
for lbl in testLabels {
lbl.backgroundColor = .white
}
}
}
}