I have a content in the safe area that reveals details on a button tap. I would like to allow the user to drag the handle to expand the content details as well, almost like the bottom sheet behaviour.
This is what I have so far:
struct ContentView: View {
#State private var isExpanded = false
var body: some View {
ScrollView {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
.frame(maxWidth: .infinity)
.padding()
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 24) {
Capsule()
.frame(width: 50, height: 5)
.opacity(0.5)
HStack(spacing: 16) {
Image(systemName: "list.bullet")
Spacer()
VStack {
Text("Track 2")
}
.font(.caption)
Spacer()
Image(systemName: "backward.fill")
Image(systemName: "play.fill")
Image(systemName: "forward.fill")
Spacer()
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
if isExpanded {
HStack {
Image(systemName: "airplayaudio")
Image(systemName: "timer")
Spacer()
Text("Artist")
}
}
}
.padding([.bottom, .horizontal])
.padding(.top, 8)
.background(.ultraThinMaterial)
}
}
}
How can I add functionality to the Capsule to drag the handle to expand that animates in a way to stretch the content in an elastic way (not just swipe to expand, but to drag the content until the user let's go)?
You can use a DragGesture on your Capsule:
.gesture(
DragGesture()
.onEnded { value in
withAnimation(.spring()) {
let swipingUp = value.translation.height < 0
withAnimation {
isExpanded = swipingUp ? true: false
}
}
}
)
To allow for a stretch, you can use onChanged paired with a State variable:
struct ContentView: View {
#State private var isExpanded = false
#State private var stretch = CGFloat.zero
var body: some View {
ScrollView {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
.frame(maxWidth: .infinity)
.padding()
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 24) {
Capsule()
.frame(width: 50, height: 5)
.opacity(0.5)
.gesture(
DragGesture()
.onChanged { newValue in
withAnimation(.spring()) {
stretch = abs(newValue.translation.height)
}
}.onEnded { value in
withAnimation(.spring()) {
isExpanded = value.translation.height < 0
stretch = .zero
}
}
)
HStack(spacing: 16) {
Image(systemName: "list.bullet")
Spacer()
VStack {
Text("Track 2")
}
.font(.caption)
Spacer()
Image(systemName: "backward.fill")
Image(systemName: "play.fill")
Image(systemName: "forward.fill")
Spacer()
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
if isExpanded {
HStack {
Image(systemName: "airplayaudio")
Image(systemName: "timer")
Spacer()
Text("Artist")
}
}
if stretch > 0 && !isExpanded {
Color.clear
.frame(height: stretch)
}
}.padding([.bottom, .horizontal])
.padding(.top, 8)
.background(.ultraThinMaterial)
}
}
}
Related
Get that strange glitch and can't fix it.
Source of problem - TabView and changing size of child views with animation.
Changing UITabbar appearance not helped. Changing safe area options not helped.
UITabBar.appearance().isHidden = false and opaque appearance give flickering of whole tabbar.
I want to hide default UITabbar to customize my own.
Any ideas?
Flickering of bottom safe area: Demonstration GIF
Sample project:
struct ContentView: View {
#State var selected = "second"
var body: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
FirstView()
TabView(selection: $selected) {
SecondView()
.tabItem({
Text("second")
})
.tag("second")
ThirdView()
.tabItem({
Text("third")
})
.tag("third")
}
}
HStack {
Image(systemName: selected == "second" ? "circle.fill" : "circle")
.onTapGesture {
selected = "second"
}
Image(systemName: selected == "third" ? "circle.fill" : "circle")
.onTapGesture {
selected = "third"
}
}
.frame(width: 70, height: 40, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.padding()
}
.edgesIgnoringSafeArea(.all)
.onAppear {
UITabBar.appearance().isHidden = true
}
}
}
struct FirstView: View {
#State var height:CGFloat = 200
var body: some View {
ZStack(alignment: .bottom) {
Color.red
.frame(height: height)
Image(systemName: height == 200 ? "arrow.down" : "arrow.up")
.foregroundColor(.white)
.padding(5)
.onTapGesture {
withAnimation(.easeInOut(duration: 2)) {
height = height == 200 ? 350 : 200
}
}
}
}
}
struct SecondView: View {
var body: some View {
ZStack {
Text("hello")
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(Color.green)
.onAppear {
UITabBar.appearance().isHidden = true
}
}
}
struct ThirdView: View {
var body: some View {
Color.blue
.edgesIgnoringSafeArea(.bottom)
}
}
Thanks for answers :)
Since you have your own index navigation anyways, I recommend to get rid of TabView altogether and switch views by if / else or switch statements:
struct ContentView: View {
#State var selected = "second"
var body: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
FirstView()
if selected == "second" {
SecondView()
} else {
ThirdView()
}
}
HStack {
Image(systemName: selected == "second" ? "circle.fill" : "circle")
.onTapGesture {
selected = "second"
}
Image(systemName: selected == "third" ? "circle.fill" : "circle")
.onTapGesture {
selected = "third"
}
}
.frame(width: 70, height: 40, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.padding()
}
.edgesIgnoringSafeArea(.all)
}
}
I have following problem. I want to create a vertical ScrollView with many rows. At the bottom of the view I have an info bar which appears over the scroll view because I put all the items in a ZStack. Here is my code and what it produces:
struct ProblemView: View {
var body: some View {
ZStack {
ScrollView(.vertical, showsIndicators: true) {
VStack {
ForEach(0..<100, id:\.self) {i in
HStack {
Text("Text \(i)")
.foregroundColor(.red)
Spacer()
Image(systemName: "plus")
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Divider()
}
}
}
VStack {
Spacer()
HStack {
Text("Some Info here")
Image(systemName: "info.circle")
.foregroundColor(.blue)
}
.padding()
.frame(maxWidth: .infinity)
.ignoresSafeArea()
.background(.ultraThinMaterial)
}
}
}
}
struct ProblemView_Previews: PreviewProvider {
static var previews: some View {
ProblemView()
}
}
As you can see the drag indicator is hidden behind the info frame. Also the last item can't be seen because it is also behind the other frame. What
I want is that the drag indicator stops at this info frame. Why am I using a ZStack and not just a VStack? I want that this opacity effect behind the info frame, you get when you scroll.
A edit on my preview post has been added and therefore I cannot edit it... I am just gonna post the answer as an other one then.
This is the code that fixes your problem:
import SwiftUI
struct ProblemView: View {
var body: some View {
ScrollView {
VStack {
ForEach(0..<100, id:\.self) {i in
HStack {
Text("Text \(i)")
.foregroundColor(.red)
Spacer()
Image(systemName: "plus")
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Divider()
}
}
.frame(maxWidth: .infinity)
}
.safeAreaInset(edge: .bottom) { // 👈🏻
VStack {
HStack {
Text("Some Info here")
Image(systemName: "info.circle")
.foregroundColor(.blue)
}
.padding()
.frame(maxWidth: .infinity)
.ignoresSafeArea()
.background(.ultraThinMaterial)
}
}
}
}
struct ProblemView_Previews: PreviewProvider {
static var previews: some View {
ProblemView()
}
}
We cannot control offset of indicator, but we can make all needed views visible by injecting last empty view with the same height (calculated dynamically) as info panel.
Here is possible approach. Tested with Xcode 13.2 / iOS 15.2
struct ProblemView: View {
#State private var viewHeight = CGFloat.zero
var body: some View {
ZStack {
ScrollView(.vertical, showsIndicators: true) {
VStack {
ForEach(0..<100, id:\.self) {i in
HStack {
Text("Text \(i)")
.foregroundColor(.red)
Spacer()
Image(systemName: "plus")
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Divider()
}
Color.clear
.frame(minHeight: viewHeight) // << here !!
}
}
VStack {
Spacer()
HStack {
Text("Some Info here")
Image(systemName: "info.circle")
.foregroundColor(.blue)
}
.padding()
.frame(maxWidth: .infinity)
.ignoresSafeArea()
.background(.ultraThinMaterial)
.background(GeometryReader {
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
}
}
.onPreferenceChange(ViewHeightKey.self) {
self.viewHeight = $0
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = value + nextValue()
}
}
I wanted to make a bottomsheet in SwiftUI with my own efforts, I open it using animation, but my animation doesn't work when closing, what is the reason?
I wonder if the offset value is increasing with animation, is there a problem while it is decreasing I am not very good at SwiftUI so I could not fully understand the problem.
struct ContentView: View {
#State var isOpen = false
#State var offset = UIScreen.main.bounds.height / 3
var body: some View {
ZStack {
Color.blue
.ignoresSafeArea()
Button(action: {
self.isOpen.toggle()
}, label: {
ZStack {
RoundedRectangle(cornerRadius: 25.0)
.foregroundColor(.black)
Text("Open")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
}
})
.buttonStyle(DefaultButtonStyle())
.frame(width: 300, height: 50, alignment: .center)
if isOpen {
GeometryReader { geometry in
VStack {
Spacer()
BottomSheet()
.frame(width: geometry.size.width,
height: geometry.size.height / 3,
alignment: .center)
.background(
Color.white
)
.offset(y: offset)
.onAppear(perform: {
withAnimation {
self.offset = 0
}
})
.onDisappear(perform: {
withAnimation {
self.offset = UIScreen.main.bounds.height / 3
}
})
}.ignoresSafeArea()
}
}
}
}
}
BottomSheet
struct BottomSheet: View {
var body: some View {
Text("Hello, World!")
}
}
onDisappear gets called when the view was removed, that's the reason custom animation not working :
struct ContentView: View {
#State var isOpen = false
var offset: CGFloat {
isOpen ? 0 : UIScreen.main.bounds.height / 3
}
var body: some View {
ZStack {
Color.blue
.ignoresSafeArea()
Button(action: {
self.isOpen.toggle()
}, label: {
ZStack {
RoundedRectangle(cornerRadius: 25.0)
.foregroundColor(.black)
Text("Open")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
}
})
.buttonStyle(DefaultButtonStyle())
.frame(width: 300, height: 50, alignment: .center)
GeometryReader { geometry in
VStack {
Spacer()
BottomSheet()
.frame(width:geometry.size.width,
height: geometry.size.height / 3,
alignment: .center)
.background(
Color.white
)
.offset(y: offset)
.animation(.easeInOut(duration: 0.5)) .transition(.move(edge: .bottom))
} .edgesIgnoringSafeArea(.bottom)
}
}
}
}
In my app you go through the onboarding during sign-up. That works perfectly. However, you can go through it again via the Profile Page.
The profile page code in question looks like this (embedded in a top-level NavigationView of course)
NavigationLink(destination:
EndDateView().environmentObject(OnboardingVM(coordinator: viewModel.appCoordinator))
) {
HStack {
Image(systemName: "chart.pie.fill")
.font(.title)
Text("Edit Spending Plan")
.fontWeight(.bold)
.scaledFont("Avenir", 20)
}
}
.buttonStyle(ButtonWithIcon())
This leads you to a screen where you set a Date via a date picker. All is good at this point.
When you navigate to the third page of this flow (Profile -> Datepicker -> Set Income) and interact with a SwiftUI TextField, the #EnvironmentObject reinitializes itself.
Below you will see the code snippets that show how we navigate/pass the env object around
DatePicker Navigation Code
NavigationLink(destination: SetIncomeView().environmentObject(onboardingVM)) {
PurpleNavigationButton(buttonText: onboardingVM.continueButton)
}
SetIncome Code
struct SetIncomeView: View {
#EnvironmentObject var onboardingVM: OnboardingVM
#ObservedObject var setIncomeVM = SetIncomeVM()
var body: some View {
VStack {
HStack {
CustomHeader1(text: SetIncomeContent.title)
Button(action: {
setIncomeVM.info.toggle()
}) {
Image(systemName: "info.circle")
}
}
.alert(isPresented: $setIncomeVM.info) {
Alert(title: Text(SetIncomeContent.title),
message: Text(SetIncomeContent.header),
dismissButton: .default(Text(SetIncomeContent.dismiss)))
}.padding()
Spacer()
ScrollView {
HStack {
CustomHeader2(text: SetIncomeContent.income)
TextField("", text: $onboardingVM.expectedIncomeAmount)
.keyboardType(.numberPad)
.frame(width: 100, height: 35, alignment: .center)
.scaledFont("Avenir", 20)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
HStack {
CustomHeader2(text: SetIncomeContent.onBasis)
Picker(selection: $onboardingVM.selectedBasis, label: Text("")) {
ForEach(0 ..< self.onboardingVM.basis.count) {
Text(self.onboardingVM.basis[$0])
}
}
.frame(width: 100)
.clipped()
CustomHeader2(text: SetIncomeContent.basis)
}
Button(action: {
setIncomeVM.otherIncome.toggle()
}) {
if setIncomeVM.otherIncome {
Image(systemName: "minus.circle")
} else {
Image(systemName: "plus.circle")
}
Text(SetIncomeContent.anotherSource)
.foregroundColor(.black)
.underline()
.fixedSize(horizontal: false, vertical: true)
.padding()
}
//TODO: I am getting a bug where if i type or scroll it closes the view below
HStack {
CustomHeader2(text: SetIncomeContent.income)
TextField("", text: $onboardingVM.additionalIncome)
.keyboardType(.numberPad)
.frame(width: 100, height: 35, alignment: .center)
.scaledFont("Avenir", 20)
.textFieldStyle(RoundedBorderTextFieldStyle())
}.isHidden(!setIncomeVM.otherIncome)
HStack {
CustomHeader2(text: SetIncomeContent.onBasis)
Picker(selection: $onboardingVM.additionalBasis, label: Text("")) {
ForEach(0 ..< self.onboardingVM.basis.count) {
Text(self.onboardingVM.basis[$0])
}
}
.frame(width: 100)
.clipped()
CustomHeader2(text: SetIncomeContent.basis)
}.isHidden(!setIncomeVM.otherIncome)
}
Spacer()
NavigationLink(destination: SetSavingsView().environmentObject(onboardingVM),
isActive: $setIncomeVM.savingsLink1Active) {
Button(action: {
setIncomeVM.savingsLink1Active.toggle()
}) {
Text(SetIncomeContent.noIncome)
.foregroundColor(.black)
.underline()
.fixedSize(horizontal: false, vertical: true)
.padding()
}
}
NavigationLink(destination: SetSavingsView().environmentObject(onboardingVM), isActive: $setIncomeVM.savingsLink2Active) {
Button(action: {
self.onboardingVM.saveIncomeBasis()
if !setIncomeVM.savingsLink2Active {
setIncomeVM.savingsLink2Active.toggle()
}
}) {
PurpleNavigationButton(buttonText: onboardingVM.continueButton)
}
}
}
Spacer()
}
}
Any idea why this happens?
I'm trying to build a custom list where the user can select an entry and the row will expand and show a picker. This picker should update an object (TimeItem) which stores the time information.
However, I was not able to use Binding in the ForEach Loop with Picker and I don't know why. The error message in Xcode is "The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions".
I also tried to use ForEach(Array(items.enumerated()), id: \.1) instead of ForEach(items) to get the index of the current row but that would mess up the delete animation (but only sometimes!?).
I do not want to use the same Binding for each row (for ex. self.$selectedElement.minutes) - every row should have its own Binding.
Does anybody know how to fix this issue? Thanks for helping!
class TimeItem: Identifiable, Equatable, ObservableObject {
static func == (lhs: TimeItem, rhs: TimeItem) -> Bool {
lhs.id == rhs.id
}
let id = UUID()
#Published var minutes: Int = 0
#Published var seconds: Int = 30
}
struct ContentView: View {
#State var items = [TimeItem]()
#State var selectedElement: TimeItem?
var body: some View {
ScrollView(){
VStack{
ForEach(items){ elem in
ZStack{
Rectangle()
.cornerRadius(12)
.frame(height: elem == selectedElement ? 120 : 40)
.foregroundColor(Color.gray.opacity(0.15))
Text("\(elem.minutes)")
.opacity(elem == selectedElement ? 0 : 1)
.transition(AnyTransition.scale)
if(elem == selectedElement){
HStack{
Picker(selection: elem.$minutes, label: Text("")){ // <- I can't use Binding with "elem"
ForEach(0..<60){ i in
Text("\(i)")
}
}
.frame(width: 120)
.clipped()
Picker(selection: .constant(0), label: Text("")){
ForEach(0..<60){ i in
Text("\(i)")
}
}
.frame(width: 120)
.clipped()
}
.frame(height: 120)
.clipped()
}
HStack{
Button(action: {
self.items.removeAll { $0.id == elem.id }
})
{
Image(systemName: "minus.circle.fill")
.foregroundColor(Color.red)
.font(.system(size: 22))
.padding(.leading, 10)
}
Spacer()
}
}
.padding(.horizontal)
.padding(.top)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring()){
self.selectedElement = elem
}
}
}
}
Spacer()
Button(action: {
self.items.append(TimeItem())
})
{
ZStack{
Rectangle()
.cornerRadius(12)
.frame(height: 40)
.foregroundColor(Color.gray.opacity(0.15))
Text("Add")
HStack{
Image(systemName: "plus.circle.fill")
.foregroundColor(Color.green)
.font(.system(size: 22))
.padding(.leading, 10)
Spacer()
}
}.padding()
}
}.animation(.spring(), value: items)
}
}
That case when you should do what compiler said: break up expression (ie. that big view) into distinct sub-expressions (ie. smaller subviews)
Here is fixed components (tested with Xcode 11.4 / iOS 13.4)
struct ContentView: View {
#State var items = [TimeItem]()
#State var selectedElement: TimeItem?
var body: some View {
ScrollView(){
VStack{
ForEach(items){ elem in
ItemRowView(elem: elem, selectedElement: self.$selectedElement){
self.items.removeAll { $0.id == elem.id }
}
}
}
Spacer()
AddItemView {
self.items.append(TimeItem())
}
}.animation(.spring(), value: items)
}
}
struct SelectedElementView: View {
#ObservedObject var elem: TimeItem
var body: some View {
HStack{
Picker(selection: $elem.minutes, label: Text("")){
ForEach(0..<60){ i in
Text("\(i)")
}
}
.frame(width: 120)
.clipped()
Picker(selection: .constant(0), label: Text("")){
ForEach(0..<60){ i in
Text("\(i)")
}
}
.frame(width: 120)
.clipped()
}
.frame(height: 120)
.clipped()
}
}
struct AddItemView: View {
let action: ()->()
var body: some View {
Button(action: action)
{
ZStack{
Rectangle()
.cornerRadius(12)
.frame(height: 40)
.foregroundColor(Color.gray.opacity(0.15))
Text("Add")
HStack{
Image(systemName: "plus.circle.fill")
.foregroundColor(Color.green)
.font(.system(size: 22))
.padding(.leading, 10)
Spacer()
}
}.padding()
}
}
}
struct ItemRowView: View {
#ObservedObject var elem: TimeItem
#Binding var selectedElement: TimeItem?
let action: ()->()
var body: some View {
ZStack{
Rectangle()
.cornerRadius(12)
.frame(height: elem == selectedElement ? 120 : 40)
.foregroundColor(Color.gray.opacity(0.15))
Text("\(elem.minutes)")
.opacity(elem == selectedElement ? 0 : 1)
.transition(AnyTransition.scale)
if(elem == selectedElement){
SelectedElementView(elem: elem)
}
HStack{
Button(action: action)
{
Image(systemName: "minus.circle.fill")
.foregroundColor(Color.red)
.font(.system(size: 22))
.padding(.leading, 10)
}
Spacer()
}
}
.padding(.horizontal)
.padding(.top)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring()){
self.selectedElement = self.elem
}
}
}
}