SwiftUI - onPreferenceChange is not called - swiftui

I was trying to understand how preference and onPreferenceChange works, so I was experimenting with a few hierarchies, unfortunately I am stuck right now on one situation where onPreferenceChange is not called anymore.
What is strange to me, is fact, when I (on the bottom of TestView struct) remove lines
.modifier(Border(.column))
OR
Spacer(minLength: 0)
onPreferenceChange starts working normaly/is called as I expect
I was trying to find explanation, but with no success. Can anyone explain to me, what I am doing wrong?
Here is code of my root view
public struct MyRootView: View {
public var body: some View {
TestView()
.onPreferenceChange(ChangeInfoKey.self) { changeInfo in
print("preference changed: \(changeInfo)")
}
}
}
TestView:
struct TestView: View {
#State private var changeInfo: ChangeInfo?
var body: some View {
VStack {
Group {
Button(action: {
self.changeInfo = ChangeInfo(title: "Some Title \(Int.random(in: 0...10))")
print("Change initiated: \(self.changeInfo!.title) - \(self.changeInfo!.id)")
}) {
Text("My Button")
}
.preference(key: ChangeInfoKey.self, value: changeInfo)
}
.modifier(Border(.column))
Spacer(minLength: 0)
}
}
}
Border modifier creates only visual border around view, I am not sure, if its relevant with this issue, but for those, who want to see completely all parts of my code, or try it:
struct Border: ViewModifier {
enum BorderType: RawRepresentable {
case section
case emptyColumn
case column
case widget
case debug
var rawValue: (CGFloat, CGFloat, Color) {
switch self {
case .section: return (2, 0, Color.blue)
case .emptyColumn: return (0.5, 4, Color(0xa7afb5))
case .column: return (0.5, 4, Color(0xa7afb5))
case .widget: return (0.5, 2, Color(0xa7afb5))
case .debug: return (4, 0, Color.red)
}
}
init?(rawValue: (CGFloat, CGFloat, Color)) {
fatalError("ElementBorder.init(rawValue) - not implemented")
}
}
let lineWidth: CGFloat
let color: Color
let dashLength: CGFloat
init(_ borderType: BorderType) {
self.lineWidth = borderType.rawValue.0
self.dashLength = borderType.rawValue.1
self.color = borderType.rawValue.2
}
func body(content: Content) -> some View {
if dashLength > 0 {
return AnyView(content
.overlay(
Rectangle()
.strokeBorder(style: StrokeStyle(lineWidth: self.lineWidth, dash: [self.dashLength]))
.foregroundColor(self.color)
))
} else {
return AnyView(content
.border(self.color, width: self.lineWidth)
)
}
}
}

Related

why does not withAnimation working without .animation

i have a Shake annimation extension like this,
import SwiftUI
struct WRShake: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX: amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)), y: 0))
}
}
extension View {
func wrshake(amount: CGFloat = 10, shakeUnits: Int = 4, animatableData: CGFloat) -> some View {
return modifier(WRShake(amount: amount, shakesPerUnit: shakeUnits, animatableData: animatableData))
}
}
then in the contentView, i got this
var body: some Scene {
WindowGroup {
NavigationView {
VStack {
Text("111")
.padding()
.background(Color.blue)
.wrshake(animatableData: self.shakeValue)
// .animation(.easeInOut(duration: 0.5))
.layoutPriority(1)
Button {
withAnimation(.easeInOut(duration: 1)) {
self.shakeValue += 1
}
// self.shakeValue += 1
} label: {
Text("shake")
}
}
}
}
then strange thing is,if i don't apply .animation(.easeInOut(duration: 0.5)) to then Text View, there is no animation.
anyone knows why?
thanks
I think because you are using explicit animations on the :App class and that class doesn't handle explicit animations.
Explicit animations is when you use the withAnimation block and leave the system to figure out how the animate.
And i suppose in #main class you did put the #State var shakeValue: CGFloat = 0 but becasuse it's a :App and not a View it doesn't change.
it works adding the .animation(.easeInOut(duration: 0.5)) because it returns a new view that handle the animations like when your extracting the view like ContentView().
(implicit animations are when you apply the modifier .animation(...))
WindowGroup {
NavigationView {
// StartUpView()
ContentView()
}
// .navigationViewStyle(StackNavigationViewStyle())
}
it turns out that, if i wrap everything into ContentView, it works, but why?

How can I update a ForEach without unnecessary rendering all array of ForEach, working with Binding Element in SwiftUI?

I have an array of Color, which I am using this array for rendering a View in multiple time, Each element of this array has a State role for a View that works with Binding, So we have an array as array of State, which every single element of that array is going used for feeding a View which needs Binding, the codes working, but every single small update to this array make ForEach render the all array, which is unnecessary, I want to correct or modify my code to stop this unnecessary renders! For example when I change 1 element to Color.black, it is understandable that SwiftUI render a new View for this element but in the fact my codes make SwiftUI render all array! or when I add new element to end of array, the same thing happens! How can I solve this problem? thanks.
PS: if you think that index or id:.self make this issue, I have to say No, because I must and I have to use index, because Binding needs an State object, and it is only possible with index, I cannot use item version of ForEach, because Binding cannot update it.
var randomColor: Color { return Color(red: Double.random(in: 0...1), green: Double.random(in: 0...1), blue: Double.random(in: 0...1)) }
struct BindingWay: View {
#State private var arrayOfColor: [Color] = [Color]()
var body: some View {
VStack(spacing: 0) {
ForEach(arrayOfColor.indices, id:\.self) { index in
CircleViewBindingWay(colorOfCircle: $arrayOfColor[index])
}
Spacer()
Button("append new Color") {
arrayOfColor.append(randomColor)
}
.padding(.bottom)
Button("update last element color to black") {
if arrayOfColor.count > 0 {
arrayOfColor[arrayOfColor.count - 1] = Color.black
}
}
.padding(.bottom)
}
.shadow(radius: 10)
}
}
struct CircleViewBindingWay: View {
#Binding var colorOfCircle: Color
init(colorOfCircle: Binding<Color>) { print("initializing CircleView"); _colorOfCircle = colorOfCircle }
var body: some View {
print("rendering CircleView")
return Circle()
.fill(colorOfCircle)
.frame(width: 50, height: 50, alignment: .center)
.onTapGesture { colorOfCircle = colorOfCircle.opacity(0.5) }
}
}
The following works:
struct CircleViewBindingWay: View {
#Binding var colorOfCircle: Color
init(colorOfCircle: Binding<Color>) { print("initializing CircleView"); _colorOfCircle = colorOfCircle }
var body: some View {
print("rendering CircleView")
return Circle()
.fill(colorOfCircle)
.frame(width: 50, height: 50, alignment: .center)
.onTapGesture { colorOfCircle = colorOfCircle.opacity(0.5) }
}
}
extension CircleViewBindingWay : Equatable { //<-- here
static func == (lhs: CircleViewBindingWay, rhs: CircleViewBindingWay) -> Bool {
lhs.colorOfCircle == rhs.colorOfCircle
}
}
struct ContentView: View {
#State private var arrayOfColor: [Color] = [Color]()
var randomColor: Color { return Color(red: Double.random(in: 0...1), green: Double.random(in: 0...1), blue: Double.random(in: 0...1)) }
var body: some View {
VStack(spacing: 0) {
ForEach(arrayOfColor.indices, id: \.self) { index in
CircleViewBindingWay(colorOfCircle: .init(get: { () -> Color in //<-- here
arrayOfColor[index]
}, set: { (newValue) in
arrayOfColor[index] = newValue
}))
}
Spacer()
Button("append new Color") {
arrayOfColor.append(randomColor)
}
.padding(.bottom)
Button("update last element color to black") {
if arrayOfColor.count > 0 {
arrayOfColor[arrayOfColor.count - 1] = Color.black
}
}
.padding(.bottom)
}
.shadow(radius: 10)
}
}
What has to happen:
CircleViewBindingWay conforms to Equatable and checks that the colors are the same. ForEach does the equatable check itself, which is why actually attaching .equatable() isn't necessary
The Binding is declared inline. There must be another equatable check that ForEach/SwiftUI does on the $arrayOfColor that fails, but this inline one passes.

Programatically scroll to SwiftUI list position? [duplicate]

It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.
So how would I do it in most simple & light way?
scroll List to end
scroll List to top
and others
(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)
Note: Row content design is out of consideration scope
Tested with Xcode 12b / iOS 14
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct ContentView: View {
#StateObject var vm = ScrollToModel()
let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
ScrollViewReader { sp in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}
SWIFTUI 1.0+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}
Solution:
Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.
struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader from iOS 14 and with Xcode 12
struct ContentView: View {
let items = (1...100)
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(items, id: \.self) { Text("\($0)"); Divider() }
}
HStack {
Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
}
}
}
}
Note that ScrollViewReader should support all scrollable content, but now it only supports ScrollView
Preview
Preferred way
This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
The strings should be defined in one place, outside of the body property.
Legacy answer
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself.
This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for MacOS:
struct ListScrollingHelper: NSViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView {
return NSView() // managed by SwiftUI, no overloads
}
func updateNSView(_ nsView: NSView, context: Context) {
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
}
}
class ListScrollingProxy {
//updated for mac osx
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView) {
//if nil == scrollView { //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height {
rect.origin.y = documentHeight - scroller.contentSize.height
}
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
}
}
}
extension NSView {
func enclosingScrollView() -> NSScrollView? {
var next: NSView? = self
repeat {
next = next?.superview
if let scrollview = next as? NSScrollView {
return scrollview
}
} while next != nil
return nil
}
}
my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top.
(this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )
struct ContentView: View {
#State private var previousIndex : Int? = nil
#State private var items = Array(0...100)
func removeRows(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
}
var body: some View {
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) { Text("\($0)")
}.onDelete(perform: removeRows)
}.onChange(of: previousIndex) { (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
}
}
}
}
This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { value in
VStack {
Button("Scroll to top") {
value.scrollTo(0)
}
Button("Scroll to buttom") {
value.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new scrollViewReader:
struct ScrollingHelperInjection: NSViewRepresentable {
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView {
return NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
helper.catchProxy(for: proxy)
}
}
final class ScrollingHelper {
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy) {
self.proxy = proxy
}
func scrollTo(_ point: Int) {
if let scroller = proxy {
withAnimation() {
scroller.scrollTo(point)
}
} else {
//problem
}
}
}
Environmental object:
#Published var scrollingHelper = ScrollingHelper()
In the view: ScrollViewReader { reader in .....
Injection in the view:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
Usage outside the view hierarchy: scrollingHelper.scrollTo(3)
As mentioned in #lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
Another cool way is to just use namespace wrappers:
A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
struct ContentView: View {
#Namespace private var topID
#Namespace private var bottomID
let items = (0..<100).map { $0 }
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Section {
LazyVStack {
ForEach(items.indices, id: \.self) { index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
}
}
} header: {
HStack {
Text("header")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(bottomID)
}
}
) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(topID)
} footer: {
HStack {
Text("Footer")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(topID) }
}
) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(bottomID)
}
.padding()
}
}
.foregroundColor(.white)
.background(.black)
}
}
Two parts:
Wrap the List (or ScrollView) with ScrollViewReader
Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().
The example below uses a notification for simplicity (use a function if you can instead!).
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)

SwiftUI: How to animate a TabView selection?

When tapping a TabView .tabItem in SwiftUI, the destination view associated with the .tabItem changes.
I tried around with putting
.animation(.easeInOut)
.transition(.slide)
as modifiers for the TabView, for the ForEach within, and for the .tabItem - but there is always a hard change of the destination views.
How can I animated that change, for instance, to slide in the selected view, or to cross dissolve it?
I checked Google, but found nothing about that problem...
for me, it works simply, it is a horizontal list, check //2 // 3
TabView(selection: $viewModel.selection.value) {
ForEach(viewModel.dates.indices) { index in
ZStack {
Color.white
horizontalListViewItem(item: viewModel.dates[index])
.tag(index)
}
}
}
.frame(width: UIScreen.main.bounds.width - 160, height: 80)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.animation(.easeInOut) // 2
.transition(.slide) // 3
Update Also Check Patrick's comment to this answer below :)
Since .animation is deprecated, use animation with equatable for same.
Try replacing :
.animation(.easeInOut)
.transition(.slide)
with :
#State var tabSelection = 0
// ...
.animation(.easeOut(duration: 0.2), value: tabSelection)
Demo
I have found TabView to be quite limited in terms of what you can do. Some limitations:
custom tab item
animations
So I set out to create a custom tab view. Here's using it with animation
Here's the usage of the custom tab view
struct ContentView: View {
var body: some View {
CustomTabView {
Text("Hello, World!")
.customTabItem {
Text("A")}
.customTag(0)
Text("Hola, mondo!")
.customTabItem { Text("B") }
.customTag(2)
}.animation(.easeInOut)
.transition(.slide)
}
}
Code
And here's the entirety of the custom tab view
typealias TabItem = (tag: Int, tab: AnyView)
class Model: ObservableObject {
#Published var landscape: Bool = false
init(isLandscape: Bool) {
self.landscape = isLandscape // Initial value
NotificationCenter.default.addObserver(self, selector: #selector(onViewWillTransition(notification:)), name: .my_onViewWillTransition, object: nil)
}
#objc func onViewWillTransition(notification: Notification) {
guard let size = notification.userInfo?["size"] as? CGSize else { return }
landscape = size.width > size.height
}
}
extension Notification.Name {
static let my_onViewWillTransition = Notification.Name("CustomUIHostingController_viewWillTransition")
}
class CustomUIHostingController<Content> : UIHostingController<Content> where Content : View {
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
NotificationCenter.default.post(name: .my_onViewWillTransition, object: nil, userInfo: ["size": size])
super.viewWillTransition(to: size, with: coordinator)
}
}
struct CustomTabView<Content>: View where Content: View {
#State private var currentIndex: Int = 0
#EnvironmentObject private var model: Model
let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
GeometryReader { geometry in
return ZStack {
// pages
// onAppear on all pages are called only on initial load
self.pagesInHStack(screenGeometry: geometry)
}
.overlayPreferenceValue(CustomTabItemPreferenceKey.self) { preferences in
// tab bar
return self.createTabBar(screenGeometry: geometry, tabItems: preferences.map {TabItem(tag: $0.tag, tab: $0.item)})
}
}
}
func getTabBarHeight(screenGeometry: GeometryProxy) -> CGFloat {
// https://medium.com/#hacknicity/ipad-navigation-bar-and-toolbar-height-changes-in-ios-12-91c5766809f4
// ipad 50
// iphone && portrait 49
// iphone && portrait && bottom safety 83
// iphone && landscape 32
// iphone && landscape && bottom safety 53
if UIDevice.current.userInterfaceIdiom == .pad {
return 50 + screenGeometry.safeAreaInsets.bottom
} else if UIDevice.current.userInterfaceIdiom == .phone {
if !model.landscape {
return 49 + screenGeometry.safeAreaInsets.bottom
} else {
return 32 + screenGeometry.safeAreaInsets.bottom
}
}
return 50
}
func pagesInHStack(screenGeometry: GeometryProxy) -> some View {
let tabBarHeight = getTabBarHeight(screenGeometry: screenGeometry)
let heightCut = tabBarHeight - screenGeometry.safeAreaInsets.bottom
let spacing: CGFloat = 100 // so pages don't overlap (in case of leading and trailing safetyInset), arbitrary
return HStack(spacing: spacing) {
self.content()
// reduced height, so items don't appear under tha tab bar
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height - heightCut)
// move up to cover the reduced height
// 0.1 for iPhone X's nav bar color to extend to status bar
.offset(y: -heightCut/2 - 0.1)
}
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * screenGeometry.size.width + -CGFloat(self.currentIndex) * spacing)
}
func createTabBar(screenGeometry: GeometryProxy, tabItems: [TabItem]) -> some View {
let height = getTabBarHeight(screenGeometry: screenGeometry)
return VStack {
Spacer()
HStack(spacing: screenGeometry.size.width / (CGFloat(tabItems.count + 1) + 0.5)) {
Spacer()
ForEach(0..<tabItems.count, id: \.self) { i in
Group {
Button(action: {
self.currentIndex = i
}) {
tabItems[i].tab
}.foregroundColor(self.currentIndex == i ? .blue : .gray)
}
}
Spacer()
}
// move up from bottom safety inset
.padding(.bottom, screenGeometry.safeAreaInsets.bottom > 0 ? screenGeometry.safeAreaInsets.bottom - 5 : 0 )
.frame(width: screenGeometry.size.width, height: height)
.background(
self.getTabBarBackground(screenGeometry: screenGeometry)
)
}
// move down to cover bottom of new iphones and ipads
.offset(y: screenGeometry.safeAreaInsets.bottom)
}
func getTabBarBackground(screenGeometry: GeometryProxy) -> some View {
return GeometryReader { tabBarGeometry in
self.getBackgrounRectangle(tabBarGeometry: tabBarGeometry)
}
}
func getBackgrounRectangle(tabBarGeometry: GeometryProxy) -> some View {
return VStack {
Rectangle()
.fill(Color.white)
.opacity(0.8)
// border top
// https://www.reddit.com/r/SwiftUI/comments/dehx9t/how_to_add_border_only_to_bottom/
.padding(.top, 0.2)
.background(Color.gray)
.edgesIgnoringSafeArea([.leading, .trailing])
}
}
}
// MARK: - Tab Item Preference
struct CustomTabItemPreferenceData: Equatable {
var tag: Int
let item: AnyView
let stringDescribing: String // to let preference know when the tab item is changed
var badgeNumber: Int // to let preference know when the badgeNumber is changed
static func == (lhs: CustomTabItemPreferenceData, rhs: CustomTabItemPreferenceData) -> Bool {
lhs.tag == rhs.tag && lhs.stringDescribing == rhs.stringDescribing && lhs.badgeNumber == rhs.badgeNumber
}
}
struct CustomTabItemPreferenceKey: PreferenceKey {
typealias Value = [CustomTabItemPreferenceData]
static var defaultValue: [CustomTabItemPreferenceData] = []
static func reduce(value: inout [CustomTabItemPreferenceData], nextValue: () -> [CustomTabItemPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
// TabItem
extension View {
func customTabItem<Content>(#ViewBuilder content: #escaping () -> Content) -> some View where Content: View {
self.preference(key: CustomTabItemPreferenceKey.self, value: [
CustomTabItemPreferenceData(tag: 0, item: AnyView(content()), stringDescribing: String(describing: content()), badgeNumber: 0)
])
}
}
// Tag
extension View {
func customTag(_ tag: Int, badgeNumber: Int = 0) -> some View {
self.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) in
guard value.count > 0 else { return }
value[0].tag = tag
value[0].badgeNumber = badgeNumber
}
.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) -> Void in
guard value.count > 0 else { return }
value[0].tag = tag
value[0].badgeNumber = badgeNumber
}
.tag(tag)
}
}
And for the tab view to detect the phone's orientation, here's what you need to add to your SceneDelegate
if let windowScene = scene as? UIWindowScene {
let contentView = ContentView()
.environmentObject(Model(isLandscape: windowScene.interfaceOrientation.isLandscape))
let window = UIWindow(windowScene: windowScene)
window.rootViewController = CustomUIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
I was having this problem myself. There's actually a pretty simple solution. Typically we supply a selection parameter just by using the binding shorthand as $selectedTab. However if we create a Binding explicitly, then we'll have the chance to apply withAnimation closure when updating the value:
#State private var selectedTab = Tabs.firstTab
TabView(
selection: Binding<ModeSwitch.Value>(
get: {
selectedTab
},
set: { targetTab in
withAnimation {
selectedTab = targetTab
}
}
),
content: {
...
}
)
Now, there is a new idea.
swiftUI 2.0 xcode 12.2
tableView(){}.tabViewStyle(PageTabViewStyle())
😄

How to apply .onHover to individual elements in SwiftUI

I am trying to animate individual items on mouseover. The issue I am having is that every item gets animated on mouseover of an item instead of just that specific item.
Here is what I have:
struct ContentView : View {
#State var hovered = false
var body: some View {
VStack(spacing: 90) {
ForEach(0..<2) {_ in
HStack(spacing: 90) {
ForEach(0..<4) {_ in
Circle().fill(Color.red).frame(width: 50, height: 50)
.scaleEffect(self.hovered ? 2.0 : 1.0)
.animation(.default)
.onHover { hover in
print("Mouse hover: \(hover)")
self.hovered.toggle()
}
}
}
}
}
.frame(minWidth:300,maxWidth:.infinity,minHeight:300,maxHeight:.infinity)
}
}
It needs to change onHover view on per-view base, ie. store some identifier of hovered view.
Here is possible solution. Tested with Xcode 11.4.
struct TestOnHoverInList : View {
#State var hovered: (Int, Int) = (-1, -1)
var body: some View {
VStack(spacing: 90) {
ForEach(0..<2) {i in
HStack(spacing: 90) {
ForEach(0..<4) {j in
Circle().fill(Color.red).frame(width: 50, height: 50)
.scaleEffect(self.hovered == (i,j) ? 2.0 : 1.0)
.animation(.default)
.onHover { hover in
print("Mouse hover: \(hover)")
if hover {
self.hovered = (i, j) // << here !!
} else {
self.hovered = (-1, -1) // reset
}
}
}
}
}
}
.frame(minWidth:300,maxWidth:.infinity,minHeight:300,maxHeight:.infinity)
}
}
Every item currently gets animated because they are all relying on hovered to see if the Circle is hovered over. To fix that, we can make every circle have their own hovered state.
struct CircleView: View {
#State var hovered = false
var body: some View {
Circle().fill(Color.red).frame(width: 50, height: 50)
.scaleEffect(self.hovered ? 2.0 : 1.0)
.animation(.default)
.onHover { hover in
print("Mouse hover: \(hover)")
self.hovered.toggle()
}
}
}
and in the ForEach we can just call the new CircleView where every Circle has their own source of truth.
struct ContentView : View {
var body: some View {
VStack(spacing: 90) {
ForEach(0..<2) { _ in
HStack(spacing: 90) {
ForEach(0..<4) { _ in
CircleView()
}
}
}
}
.frame(minWidth:300,maxWidth:.infinity,minHeight:300,maxHeight:.infinity)
}
}
Alternatively, you can create a modifier that allows you to change the View in question when it's hovered:
extension View {
func onHover<Content: View>(#ViewBuilder _ modify: #escaping (Self) -> Content) -> some View {
modifier(HoverModifier { modify(self) })
}
}
private struct HoverModifier<Result: View>: ViewModifier {
#ViewBuilder let modifier: () -> Result
#State private var isHovering = false
func body(content: Content) -> AnyView {
(isHovering ? modifier().eraseToAnyView() : content.eraseToAnyView())
.onHover { self.isHovering = $0 }
.eraseToAnyView()
}
}
Then each Circle on your example would go something like:
Circle().fill(Color.red).frame(width: 50, height: 50)
.animation(.default)
.onHover { view in
_ = print("Mouse hover")
view.scaleEffect(2.0)
}