0

Bottom sheet can now be dragged vertically. I want to close the bottom sheet when the bottom sheet reaches near the bottom of the iphone screen while dragging.

It seems that value(DragGesture.Value), which can be received by onChanged and onEnded, has a different position that is not the screen position.

I think there are two solutions.

  1. If you know the drag position based on the screen size, write the close process using it. (I don't know how to get the drag position based on the screen size.)

  2. value.location will tell you the distance moved by dragging, so close the bottom sheet when the distance is near to the content size (height) of the bottomsheet. (I don't know how to get the height of the content size and use it in onChanged and onEnded)

If my idea is wrong or you have other ideas, please let me know.

Code:

import SwiftUI

struct ContentView: View {
  @State private var isShow = false

  var body: some View {
    ZStack {
      Button(
        "Show Sheet",
        action: {
          self.isShow.toggle()
        }
      )
      .zIndex(0)
      .allowsHitTesting(!isShow)

      BottomSheet(
        isShow: self.$isShow,
        content: {
          VStack {
            Text("A")
            Text("B")
            Text("C")
            Text("A")
            Text("B")
            Text("C")
            Text("A")
            Text("B")
            Text("C")
          }
          .frame(
            maxWidth: .infinity
          )
          .background(Color(.yellow))
        }
      )
      .zIndex(1)
    }
  }
}

struct ScrimView: View {
  var body: some View {
    VStack {}.frame(
      maxWidth: .infinity,
      maxHeight: .infinity,
      alignment: .bottom
    )
    .background(
      Color(.gray)
    )
    .opacity(0.5)
  }
}

struct BottomSheet<Content: View>: View {
  private let content: () -> Content
  @Binding var isShow: Bool
  @State var offsetY: CGFloat = 0

  init(
    isShow: Binding<Bool>,
    content: @escaping () -> Content
  ) {
    self._isShow = isShow
    self.content = content
  }

  var body: some View {
    ZStack(alignment: .bottom) {
      if self.isShow {
        ScrimView().zIndex(
          0
        )
        .onTapGesture {
          self.isShow = false
        }
        .transition(.opacity)

        VStack {
          Button(
            "X",
            action: {
              self.isShow = false
            }
          )
          self.content()
        }
        .zIndex(1)
        .background(Color(.white))
        .cornerRadius(10)
        .offset(y: self.offsetY)
        .gesture(self.gesture)
        .transition(.move(edge: .bottom))
      }
    }
    .animation(.default)
    .onChange(
      of: self.isShow,
      perform: { value in
        self.offsetY = 0
      }
    )
  }

  var gesture: some Gesture {
    DragGesture().onChanged{ value in
      guard value.translation.height > 0 else { return }

      self.offsetY = value.translation.height

      // I want to close the bottom sheet when the bottom sheet reaches near the bottom of the screen by gesture
    }
    .onEnded{ value in
      if value.translation.height > 0 {
        withAnimation {
          self.offsetY = 0
          return
        }
      } else {
        return
      }
    }
  }
}

I just need to determine the vertical threshold for the close, but since this bottom sheet gets its content from the outside, this threshold needs to be determined dynamically from the height of the content.

If I can't get the height of the content, then I need to calculate the close position by the position of the bottom of the screen and the gesture position.

shingo.nakanishi
  • 2,045
  • 2
  • 21
  • 55

1 Answers1

0

I came across the answer in the process of writing this question.

When I used ZStack as shown in the reference code, the transition animation did not work as expected. Therefore, I removed the ZStack from the ChildSizeReader.

Code:

import SwiftUI

struct ContentView: View {
  @State private var isShow = false

  var body: some View {
    ZStack {
      Button(
        "Show Sheet",
        action: {
          self.isShow.toggle()
        }
      )
      .zIndex(0)
      .allowsHitTesting(!isShow)

      BottomSheet(
        isShow: self.$isShow,
        content: {
          VStack {
            Text("A")
            Text("B")
            Text("C")
            Text("A")
            Text("B")
            Text("C")
            Text("A")
            Text("B")
            Text("C")
          }
          .frame(
            maxWidth: .infinity
          )
          .background(Color(.yellow))
        }
      )
      .zIndex(1)
    }
  }
}

struct ScrimView: View {
  var body: some View {
    VStack {}.frame(
      maxWidth: .infinity,
      maxHeight: .infinity,
      alignment: .bottom
    )
    .background(
      Color(.gray)
    )
    .opacity(0.5)
  }
}

struct BottomSheet<Content: View>: View {
  private let content: () -> Content
  @Binding var isShow: Bool
  @State var offsetY: CGFloat = 0
  @State var size: CGSize = .zero

  init(
    isShow: Binding<Bool>,
    content: @escaping () -> Content
  ) {
    self._isShow = isShow
    self.content = content
  }

  var body: some View {
    ZStack(alignment: .bottom) {
      if self.isShow {
        ScrimView().zIndex(
          0
        )
        .onTapGesture {
          self.isShow = false
        }
        .transition(.opacity)

        ChildSizeReader(size: $size) {
          VStack {
            Button(
              "X",
              action: {
                self.isShow = false
              }
            )
            self.content()
          }
          .zIndex(1)
          .background(Color(.white))
          .cornerRadius(10)
          .offset(y: self.offsetY)
          .gesture(self.gesture)
          .transition(.move(edge: .bottom))
        }
      }
    }
    .animation(.default)
    .onChange(
      of: self.isShow,
      perform: { value in
        self.offsetY = 0
      }
    )
  }

  var gesture: some Gesture {
    DragGesture().onChanged{ value in
      guard value.translation.height > 0 else { return }

      self.offsetY = value.translation.height
    }
    .onEnded{ value in
      if value.translation.height < self.size.height / 2 {
        withAnimation {
          self.offsetY = 0
        }
      } else {
        self.isShow = false
      }
    }
  }
}

struct ChildSizeReader<Content: View>: View {
  @Binding var size: CGSize

  let content: () -> Content
  var body: some View {
    content().background(
      GeometryReader { proxy in
        Color.clear.preference(
          key: SizePreferenceKey.self,
          value: proxy.size
        )
      }
    )
    .onPreferenceChange(SizePreferenceKey.self) { preferences in
      self.size = preferences
    }
  }
}

struct SizePreferenceKey: PreferenceKey {
  typealias Value = CGSize
  static var defaultValue: Value = .zero

  static func reduce(value _: inout Value, nextValue: () -> Value) {
    _ = nextValue()
  }
}
shingo.nakanishi
  • 2,045
  • 2
  • 21
  • 55