How to apply .onHover to individual elements in SwiftUI - 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)
}

Related

LazyGridView how to detect and act on item overflowing screen?

I have a grid of items. Each item can expand height. I want to autoscroll when the item is expanded so it doesn't overflow the screen.
I was successful with the following code but I had to revert to a hack.
The idea was to detect when the item is overflowing using a Geometry reader on the item's background. Works wonders.
The issue is that when the view is expanded , the geo reader will update after the condition to check if autoscroll should execute is ran by the dispatcher. Hence my ugly hack.
Wonder what is the proper way ?
import SwiftUI
struct BlocksGridView: View {
private var gridItemLayout = [GridItem(.adaptive(minimum: 300, maximum: .infinity), spacing: 20)]
var body: some View {
ZStack{
ScrollView {
ScrollViewReader { value in
LazyVGrid(columns: gridItemLayout, spacing: 20) {
ForEach((0..<20), id: \.self) {
BlockView(cardID: $0,scrollReader: value).id($0)
}
}
}
.padding(20)
}
}
}
}
struct BlockView : View {
var cardID : Int
var scrollReader : ScrollViewProxy
#State private var isOverflowingScreen = false
#State private var expand = false
var body: some View {
ZStack{
Rectangle()
.foregroundColor(isOverflowingScreen ? Color.blue : Color.green)
.frame(height: expand ? 300 : 135)
.clipShape(Rectangle()).cornerRadius(14)
.overlay(Text(cardID.description))
.background(GeometryReader { geo -> Color in
DispatchQueue.main.async {
if geo.frame(in: .global).maxY > UIScreen.main.bounds.maxY {
isOverflowingScreen = true
} else {
isOverflowingScreen = false
}
}
return Color.clear
})
.onTapGesture {
expand.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // <-- Hack :(
if isOverflowingScreen {
withAnimation{
scrollReader.scrollTo(cardID)
}
}
}
}
}
}
}
struct BlocksGridView_Previews: PreviewProvider {
static var previews: some View {
BlocksGridView()
}
}
Blue items are overflowing ...

Scrollview conflicting with another scrollview. Swiftui

I have a scrollview who's content expands to fill the screen and stacks in front using the zIndex. The expanded content also has a scrollview for the content inside. I'm literally trying to mimic the Apps Stores "Today" tab with expanding cards and scrollable content inside.
The way I built this though I realized the expanding view is still part of the parent scrollview. As a result the scrollviews are nested and conflict. This was not what I intended.
Im very new to programming. This is the code that basically expands each card. Its pretty basic. A ternary expands the cards. The CardView is the cards content. Attached is a screenshot of what I'm trying to achieve. Need help here. Any info would be great. Been searching the internet for a way to do this right.
struct Media: View {
#EnvironmentObject var vm : ViewModel
#Environment(\.colorScheme) var mode
#State var showCard = false
#State var activeCard = -1
let height = UIScreen.main.bounds.height
var body: some View {
ZStack {
ScrollView (.vertical, showsIndicators: false) {
VStack (alignment:.center, spacing: 30) {
ForEach (vm.cards.indices, id: \.self) { i in
let z=vm.cards[i].z
GeometryReader { geom in
ZStack {
CardView (showCard: $showCard,
activeCard: self.$activeCard,
zValue: $vm.cards[i].z,
i: i,
cards: vm.cards[i])
}//Z
.frame(minHeight: showCard && activeCard == i ? height : nil) //Animates Card Scaling
.padding(.horizontal, showCard && activeCard == i ? 0:20) // Card Padding
.offset(y: i==activeCard ? -geom.frame(in: .global).minY : 0)
} //GEOM
.frame(minHeight: 450)
.zIndex(z)
} //LOOP
} //V
.padding([.bottom, .top])
} //SCROLLVIEW
} //Z
.background(Color("Background Gray"))
}
}
I don't see a problem with the nested ScrollViews, as you put the DetailView on top. I tried to rebuild a simplified version from your code, see below.
BTW: You don't need the outer ZStack.
And at some point you should consider using ForEach over the cards, not over the indices, otherwise you'll run into UI update problems when inserting or deleting cards. I left it for now to stay closer to your original.
struct ContentView: View {
#State var activeCard = -1
let height = UIScreen.main.bounds.height
var body: some View {
ScrollView (.vertical, showsIndicators: false) {
VStack (alignment:.center, spacing: 30) {
ForEach (data.indices, id: \.self) { i in
GeometryReader { geom in
ZStack {
DetailCellView(entry: data[i], isActive: activeCard == i)
.onTapGesture {
if activeCard == i {
activeCard = -1
} else {
activeCard = i
}
}
}//Z
.frame(minHeight: activeCard == i ? height : nil) //Animates Card Scaling
.padding(.horizontal, activeCard == i ? 0 : 20) // Card Padding
.offset(y: activeCard == i ? -geom.frame(in: .global).minY : 0)
} //GEOM
.frame(minHeight: 450)
.zIndex(activeCard == i ? 1 : 0)
.animation(.default, value: activeCard)
} //LOOP
} //V
// .padding([.bottom, .top])
} //SCROLLVIEW
.background(.gray)
}
}
for the while being: a custom outer dragview as workaround ...
struct ContentView: View {
#State var activeCard = -1
let height = UIScreen.main.bounds.height
#State private var offset = CGFloat.zero
#State private var drag = CGFloat.zero
var body: some View {
GeometryReader { geo in
VStack (alignment:.center, spacing: 30) {
ForEach (data.indices, id: \.self) { i in
GeometryReader { geom in
ZStack {
DetailCellView(entry: data[i],
isActive: activeCard == i)
.onTapGesture {
if activeCard == i {
activeCard = -1
} else {
activeCard = i
}
}
}
.frame(minHeight: activeCard == i ? height : nil) //Animates Card Scaling
.padding(.horizontal, activeCard == i ? 0 : 20) // Card Padding
.offset(y: activeCard == i ? -geom.frame(in: .global).minY : 0)
}
.frame(minHeight: 400)
.zIndex(activeCard == i ? 1 : 0)
.animation(.default, value: activeCard)
}
}
// custom drag view
.offset(x: 0 , y: offset + drag)
.background(.gray)
// drag
.gesture(DragGesture()
.onChanged({ value in
drag = value.translation.height
})
.onEnded({ value in
print(offset)
withAnimation(.easeOut) {
offset += value.predictedEndTranslation.height
offset = min(offset, 0)
offset = max(offset, -CGFloat(data.count * (400 + 30)) + height )
drag = 0
}
print(offset)
})
)
}
}
}
So I figured out how to accomplish what I was trying to accomplish.
struct ScrollingHelper: UIViewRepresentable {
let proxy: ScrollingProxy // 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
}
}
class ScrollingProxy {
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func disableScrolling(_ flag: Bool) {
scrollView?.isScrollEnabled = flag
print(flag)
}
}
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
}
}
I used the above code from https://stackoverflow.com/a/60855853/12299030 &
Disable Scrolling in SwiftUI List/Form
Then used in a TapGesture
scrollEnabled = false
scrollProxy.disableScrolling(scrollEnabled)
and used background modifier:
.background(ScrollingHelper(proxy: scrollProxy))

SwiftUI - How to make a selectable scrollable list?

I'm trying to make something like this:
before scrolling
after scrolling
Essentially:
The items are arranged in a scrollable list
The item located at the center is the one selected
The selected item's properties are accessible (by updating #State variables)
Ideally the scroll gesture is "sticky." For example, whichever item closest to the center after scrolling readjusts its position to the center, so that the overall arrangement is the same.
I've tried using a ScrollView but I have no idea of how to implement 2 and 4. I guess the idea is quite similar to a Picker?
I've been stuck on this for a while. Any suggestion would be greatly appreciated. Thanks in advance!
You could detect the location of items to select the centred one.
// Data model
struct Item: Identifiable {
let id = UUID()
var value: Int
// Other properties...
var loc: CGRect = .zero
}
struct ContentView: View {
#State private var ruler: CGFloat!
#State private var items = (0..<10).map { Item(value: $0) }
#State private var centredItem: Item!
var body: some View {
HStack {
if let item = centredItem {
Text("\(item.value)")
}
HStack(spacing: -6) {
Rectangle()
.frame(height: 1)
.measureLoc { loc in
ruler = (loc.minY + loc.maxY) / 2
}
Image(systemName: "powerplug.fill")
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
ForEach($items) { $item in
Text("\(item.value)")
.padding()
.frame(width: 80, height: 80)
.background(centredItem != nil &&
centredItem.id == item.id ? .yellow : .white)
.border(.secondary)
.measureLoc { loc in
item.loc = loc
if let ruler = ruler {
if item.loc.maxY >= ruler && item.loc.minY <= ruler {
withAnimation(.easeOut) {
centredItem = item
}
}
// Move outsides
if ruler <= items.first!.loc.minY ||
ruler >= items.last!.loc.maxY {
withAnimation(.easeOut) {
centredItem = nil
}
}
}
}
}
}
// Extra space above and below
.padding(.vertical, ruler)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Detect location:
struct LocKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}
extension View {
func measureLoc(_ perform: #escaping (CGRect) ->()) -> some View {
overlay(GeometryReader { geo in
Color.clear
.preference(key: LocKey.self, value: geo.frame(in: .global))
}.onPreferenceChange(LocKey.self, perform: perform))
}
}

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())
😄

SwiftUI/PreferenceKey: How to avoid moving rects when scrolling?

I'm working on a tab bar that is scrollable and that has a moving background for the selected tab.
The solution is based on PreferenceKeys; however, I have a problem to get the moving background stable in relation to the tabs. Currently, it moves when scrolling, which is not desired; instead, it shall be fixed in relation to the tab item and scroll with them.
Why is this the case, and how to avoid that? When removing the ScrollView, the background moves correctly to the selected tab item. The TabItemButton is just a Button with some special label.
struct TabBar: View {
#EnvironmentObject var service: IRScrollableTabView.Service
// We support up to 15 items.
#State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 15)
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: geo.size.width))
.animation(.easeInOut(duration: 0.3))
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = selectedRect.minX + selectedRect.size.width / 2 - width / 2
debugPrint(selectedOffset)
return selectedOffset
}
}
struct Setter: View {
let index: Int
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.clear)
.preference(key: IRPreferenceKey.self,
value: [IRData(viewIndex: self.index,
rect: geo.frame(in: .named("IRReference")))])
}
}
}
struct IRPreferenceKey: PreferenceKey {
typealias Value = [IRData]
static var defaultValue: [IRScrollableTabView.IRData] = []
static func reduce(value: inout [IRScrollableTabView.IRData], nextValue: () -> [IRScrollableTabView.IRData]) {
value.append(contentsOf: nextValue())
}
}
struct IRData: Equatable {
let viewIndex: Int
let rect: CGRect
}
The service is defined this way (i.e., nothing special...):
final class Service: ObservableObject {
#Published var currentDestinationView: AnyView
#Published var tabItems: [IRScrollableTabView.Item]
#Published var selectedIndex: Int { didSet { debugPrint("selectedIndex: \(selectedIndex)") } }
init(initialDestinationView: AnyView,
tabItems: [IRScrollableTabView.Item],
initialSelectedIndex: Int) {
self.currentDestinationView = initialDestinationView
self.tabItems = tabItems
self.selectedIndex = initialSelectedIndex
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var title: String
var image: Image = Image(systemName: "circle")
}
I solved the problem! The trick seemed to be to put another GeometryReader around the Indicator view and to take its width for calculating the offset. The .onPreferenceChange must be attached to the HStack, and the .coordinateSpace to the ZStack. Now it's working...
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
GeometryReader { innerGeo in
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: innerGeo.size.width))
.animation(.easeInOut(duration: 0.3))
}
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = -width / 2 + CGFloat(80 * self.service.selectedIndex) + selectedRect.size.width / 2
debugPrint(selectedOffset)
return selectedOffset
}