Hiding the navigation bar on scroll was supported in Swift with navigationController?.hidesBarsOnSwipe = true
To be clear, I'd like it to only be hidden on scroll, so .navigationBarHidden(true) would not suffice.
I tried accessing the NavigationController as described in this Stackoverflow answer, (I added nc.hidesBarsOnSwipe = true) and while it compiled, it did not work.
Is this supported in SwiftUI?
NavigationView seems to be relatively buggy still. For example, by default a ScrollView will ignore the title area and just scroll beneath it.
It looks to me like you can get this working by using displayMode: .inline and StackNavigationViewStyle() together.
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
ForEach(0...20, id: \.self) { count in
(count % 2 == 0 ? Color.red : Color.blue)
.frame(height: 44.0)
}
}
.background(NavigationConfigurator { nc in // NavigationConfigurator is from the OP's post: https://stackoverflow.com/a/58427754/7834914
nc.hidesBarsOnSwipe = true
})
.navigationBarTitle("Hello World", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
Before Scroll:
After Scroll:
I've come across the same problem. Here's how i solved it.
get the scroll offset of the view
hide or view nav bar according to the offset
1. getting the scroll position
Please see here for how to do this. I'll add a sample code here.
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
import SwiftUI
struct ObservableScrollView<Content>: View where Content : View {
#Namespace var scrollSpace
#Binding var scrollOffset: CGFloat
let content: () -> Content
init(scrollOffset: Binding<CGFloat>,
#ViewBuilder content: #escaping () -> Content) {
_scrollOffset = scrollOffset
self.content = content
}
var body: some View {
ScrollView {
content()
.background(GeometryReader { geo in
let offset = -geo.frame(in: .named(scrollSpace)).minY
Color.clear
.preference(key: ScrollViewOffsetPreferenceKey.self,
value: offset)
})
}
.coordinateSpace(name: scrollSpace)
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
scrollOffset = value
}
}
}
2. hide or view nav bar according to the offset
Now we can use the above created observable scroll view.
#State var scrollOffset: CGFloat = CGFloat.zero
#State var hideNavigationBar: Bool = false
var body: some View {
NavigationView {
ObservableScrollView(scrollOffset: self.$scrollOffset) {
Text("I'm observable")
}
.navigationTitle("Title")
.onChange(of: scrollOffset, perform: { scrollOfset in
let offset = scrollOfset + (self.hideNavigationBar ? 50 : 0) // note 1
if offset > 60 { // note 2
withAnimation(.easeIn(duration: 1), {
self.hideNavigationBar = true
})
}
if offset < 50 {
withAnimation(.easeIn(duration: 1), {
self.hideNavigationBar = false
})
}
})
.navigationBarHidden(hideNavigationBar)
}
}
Note 1: Assume that the height of the navigation title is 50. (This will change depending on the style.) When the nav bar dissapears, scroll offset drops by that height instantly. To keep the offset consistant add the height of the nav bar to the offset if it's hidden.
Note 2: I intentionally let a small difference between two thresholds for hiding and showing instead of using the same value, Because if the user scrolls and keep it in the threshold it won't flicker.
Related
I am triggering animation after unhiding the view unfortunately animation is not working unless I tap twice
struct ContentView: View {
#State var animate = false
#State var isViewHidden: Bool = true
var body: some View {
VStack {
ZStack {
Circle()
.fill(.blue).opacity(0.25).frame(width: 40, height: 40).offset(y: self.animate ? 0 : 60)
.hides(isViewHidden)
}
.animation((Animation.linear(duration: 1.5).repeatForever(autoreverses: true))
, value: self.animate ? 0 : 60)
Spacer()
Button("Tap here") {
self.isViewHidden = false
self.animate.toggle()
}
}
.padding()
}
}
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
if isHidden {
hidden()
} else {
self
}
}
}
SwiftUI uses a before view and an after view to animate. You are introducing the view to animate at the same time you are updating self.animate, so Swift doesn't have a before view to use for the animation.
Change your View extension to this:
extension View {
#ViewBuilder
func hides(_ isHidden: Bool) -> some View {
self.opacity(isHidden ? 0 : 1)
}
}
This leaves the view onscreen at all times, but just hides it by making it invisible. That way, the view is there to animate from the start.
I have the following code:
struct TestItem:Identifiable {
var id = UUID()
var index:Int
}
struct ContentView: View {
#State var testItems = [TestItem]()
let itemsUpperBound:Int = 1000
#State var trigger = false
var columnGridItems = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
HStack {
Button {
trigger.toggle()
print("trigger: \(trigger)")
} label: {
Text("TEST")
.foregroundColor(.white)
.padding()
.background(.blue)
}
Spacer()
}
ScrollView(.horizontal) {
ScrollViewReader { sr in
LazyHGrid(rows: columnGridItems) {
ForEach(testItems) { ti in
Text("id \(ti.id)")
}
}
.border(.blue)
.onChange(of: trigger) { newValue in
withAnimation(.easeInOut(duration: 10)) {
let idx = Int.random(in: 0..<testItems.count)
let id = testItems[idx].id
print("will try to scroll to id: \(id)")
sr.scrollTo(id)
}
}
}
}
.border(.red)
}
.onAppear {
for i in 0..<itemsUpperBound {
testItems.append(TestItem(index: i))
}
}
}
}
I would like to animate the scroll view content offset, with whatever duration I'd like to set, so that the app would show the entire content of the scrollview without requiring the user to scroll it. In UIKit I could do it by animating the content offset, but, I am not sure how to do this in SwiftUI.
How can I animate the content offset of a scrollview in SwiftUI to achieve this type of animation?
I have edited the code to account for Asperi's comment and tried to use a ScrollReader. It does scroll to the desired item in the scrollview, but the duration is not working. I set it to 10 seconds, and it doesn't seem to do anything.
How can I animate the scroll using a custom duration I want?
I have reached an annoying issue with SwiftUI. I have a horizontal pager with vertical scroll views as pages. It is defined as simple as they come,
TabView(selection: $selected) {
ForEach(focus!.list.things) { thing in
FullView(thing: thing).tag(thing)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
and
struct FullView: View {
let thing: Thing
var body: some View {
ScrollView {
VStack {
...
}
}
}
}
This produces a view which does what I want, except it does not reach all the way down below the home indicator.
I can solve this by adding .ignoresSafeArea(edges: .bottom) to the TabView, but that produces another displeasing result where the page indicator collides with the home indicator.
Is there any reasonable way accomplish full height vertical scroll while keeping the index page indicator above the home indicator?
Code to recreate issue:
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
var body: some View {
NavigationView {
TabView() {
Page()
Page()
Page()
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
EDIT: See also #nekno's fantastic additions!
This is possible if you create a custom UIPageControl, manually tag each tab in the TabView, and make sure to keep track of the numberOfPages:
struct PageControlView: UIViewRepresentable {
#Binding var currentPage: Int
#Binding var numberOfPages: Int
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
struct ContentView: View {
#State var isSheetUp = false
var body: some View {
Button("Present") {
isSheetUp.toggle()
}
.sheet(isPresented: $isSheetUp) {
Sheet()
}
}
struct Sheet: View {
#State var currentPage = 0
#State var numberOfPages = 3
var body: some View {
NavigationView {
ZStack {
TabView(selection: $currentPage) {
Page().tag(0)
Page().tag(1)
Page().tag(2)
}
// Comment this to switch layout issue
.ignoresSafeArea(edges: .bottom)
.tabViewStyle(.page(indexDisplayMode: .never))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
VStack {
Spacer()
PageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
}
}
}
}
}
struct Page: View {
var body: some View {
ScrollView {
VStack {
Rectangle()
.foregroundColor(.teal)
.padding()
.frame(minHeight: 10000)
}
}.background(Color.brown)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#Coder-256's answer set me on the right path, and I added a couple enhancements you might find useful.
The UIPageControl normally iterates through the pages when you tap on it. As written, the indicator in the page control was changing, but the pages weren't actually changing, so I added a target for the page control's .valueChanged event.
When setting the current page based on the new changed value, wrapping the assignment in a withAnimation closure ensure the page animates to the next page, otherwise it just replaces the current page instantaneously.
TabView will work with any valid tag values, which just need to conform to Hashable.
To work with the page control, you need those tag values to be convertible to Int values, but it's common practice to use a strongly-typed, named value for tags, so I added support for an enum that conforms to RawRepresentable with a backing type of Int.
Others may find it easier to just use hard-coded integers for the tag values, so if you ever reordered the pages in your TabView you wouldn't have to remember to reorder the cases in your enum, but to each their own.
The UIPageControl and its parent ViewHost that hosts the UIViewRepresentable instance both have auto resizing masks that result in their frames expanding to consume the horizontal space of the containing superview.
Both the page control and the view host participate in hit testing, so they intercept touches to the left and right of the page control when you actually intend to scroll the content underneath.
Adding the allowsHitTesting(false) view modifier eliminates that behavior, but also disables all interaction with the page control, so it breaks the tap/paging functionality.
I played around with various solutions, and the easiest seems to be to just set a frame on the page control that requests a maxWidth and maxHeight of 0, and as a result the view shrinks to its intrinsic content size.
struct PageControlView<T: RawRepresentable>: UIViewRepresentable where T.RawValue == Int {
#Binding var currentPage: T
#Binding var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .prominent
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage.rawValue
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
#objc func valueChanged(sender: UIPageControl) {
guard let currentPage = T(rawValue: sender.currentPage) else {
return
}
withAnimation {
parent.currentPage = currentPage
}
}
}
}
struct ContentView: View {
#State private var currentPage: Pages = .myFirstPage
#State private var numberOfPages = Pages.allCases.count
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
MyFirstPage()
.tag(Pages.myFirstPage)
MySecondPage()
.tag(Pages.mySecondPage)
MyThirdPage()
.tag(Pages.myThirdPage)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
UIPageControlView(currentPage: $currentPage, numberOfPages: $numberOfPages)
.frame(maxWidth: 0, maxHeight: 0)
.padding(22) // 22 seems to mimic SwiftUI's `PageIndexView` placement from the bottom edge
}
}
}
extension ContentView {
enum Pages: Int, CaseIterable {
case myFirstPage
case mySecondPage
case myThirdPage
}
}
I'm trying to make my ScrollView:
Not bounce when the content is smaller than the screen
Bounce when the content overflows the screen
Here's my code:
struct ContentView: View {
init() {
UIScrollView.appearance().alwaysBounceVertical = false
}
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.frame(height: 300) /// is smaller than the screen
.padding()
}
}
}
I tried setting UIScrollView.appearance().alwaysBounceVertical = false, but the scroll view still bounces:
If I do UIScrollView.appearance().bounces = false, it stops bouncing. However, if I make the rectangle taller than the screen, it also stops bouncing (which I don't want).
Doesn't bounce (yay!)
... but doesn't bounce when the content overflows the screen
How can I disable bouncing, but only when the content is smaller than scroll view's bounds?
Well, when using SwiftUI-Introspect, setting alwaysBounceVertical to false actually works. I'm not exactly sure why setting the appearance as you did doesn't work though...
Anyway, here is some working example code:
struct ContentView: View {
#State private var count = 5
var body: some View {
VStack {
Picker("Count", selection: $count) {
Text("5").tag(5)
Text("100").tag(100)
}
.pickerStyle(SegmentedPickerStyle())
ScrollView {
VStack {
ForEach(1 ... count, id: \.self) { i in
Text("Item: \(i)")
}
}
.frame(maxWidth: .infinity)
}
.introspectScrollView { scrollView in
scrollView.alwaysBounceVertical = false
}
}
}
}
Result (unfortunately you can't see my mouse trying to drag the shorter list):
From iOS 16.4 onwards, there is a new modifier for called .scrollBounceBehavior that can be used to prevent ScrollView from bouncing when the content is smalled than the screen.
Example:
struct ContentView: View {
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.frame(height: 300)
.padding()
}
.scrollBounceBehavior(.basedOnSize)
}
}
Made a small extension based on George's answer using SwiftUI-Introspect:
extension View {
func disableBouncingWhenNotScrollable() -> some View {
introspectScrollView { scrollView in
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
}
}
}
Usage:
ScrollView {
// content
}
.disableBouncingWhenNotScrollable()
I'd like to have a drag to dismiss scroll view in SwiftUI, where if you keep dragging when it's at the top of the content (offset 0), it will instead dismiss the view.
I'm working to implement this in SwiftUI and finding it to be rather difficult. It seems like I can either recognize the DragGesture or allowing scrolling, but not both.
I need to avoid using UIViewRepresentable and solve this using pure SwiftUI or get as close as possible. Otherwise it can make developing other parts of my app difficult.
Here's an example of the problem I'm running into:
import SwiftUI
struct DragToDismissScrollView: View {
enum SeenState {
case collapsed
case fullscreen
}
#GestureState var dragYOffset: CGFloat = 0
#State var scrollYOffset: CGFloat = 0
#State var seenState: SeenState = .collapsed
var body: some View {
GeometryReader { proxy in
ZStack {
Button {
seenState = .fullscreen
} label: {
Text("Show ScrollView")
}
/*
* Works like a regular ScrollView but provides updates on the current yOffset of the content.
* Can find code for OffsetAwareScrollView in link below.
* Left out of question for brevity.
* https://gist.github.com/robhasacamera/9b0f3e06dcf27b54962ff0e077249e0d
*/
OffsetAwareScrollView { offset in
self.scrollYOffset = offset
} content: {
ForEach(0 ... 100, id: \.self) { i in
Text("Item \(i)")
.frame(maxWidth: .infinity)
}
}
.background(Color.white)
// If left at the default minimumDistance gesture isn't recognized
.gesture(DragGesture(minimumDistance: 0)
.updating($dragYOffset) { value, gestureState, _ in
// Only want to start dismissing if at the top of the scrollview
guard scrollYOffset >= 0 else {
return
}
gestureState = value.translation.height
}
.onEnded { value in
if value.translation.height > proxy.frame(in: .local).size.height / 4 {
seenState = .collapsed
} else {
seenState = .fullscreen
}
})
.offset(y: offsetForProxy(proxy))
.animation(.spring())
}
}
}
func offsetForProxy(_ proxy: GeometryProxy) -> CGFloat {
switch seenState {
case .collapsed:
return proxy.frame(in: .local).size.height
case .fullscreen:
return max(dragYOffset, 0)
}
}
}
Note: I've tried a lot solutions for the past few days (none that have worked), including:
Adding a delay to the DragGesture using the method mentioned here: https://stackoverflow.com/a/59961959/898984
Adding an empty onTapGesture {} call before the DragGesture as mentioned here: https://stackoverflow.com/a/60015111/898984
Removing the gesture and using the offset provided from the OffsetAwareScrollView when it's > 0. This doesn't work because as the ScrollView is moving down the offset decreases as the OffsetAwareScrollView catches up to the content.