SwiftUI: Why is the content of this ScrollView misplaced? - swift

The below sample code should produce a grid of RoundedRectangles inside a ScrollView. With Buttons in the NavigationBar, you can change the number of rows and columns. It works the best on the iPad, because of its bigger screen.
When you run this code, you will see (or at least, I saw) that the content of the ScrollView is not centered well. When you add some rows and columns, you can't see the topmost and leftmost ones and at the bottom and at the right there is some blank space.
Do more people experience this problem? Has anybody found a solution? Does someone know an alternative way to create a scrollable grid with flexible width and height?
import SwiftUI
struct ContentView: View {
#State var x: Int = 5
#State var y: Int = 5
var body: some View {
NavigationView {
ScrollView([.horizontal, .vertical]) {
VStack(spacing: 8) {
ForEach(0 ..< self.y, id: \.self) { yCoor in
HStack(spacing: 8) {
ForEach(0 ..< self.x, id: \.self) { xCoor in
RoundedRectangle(cornerRadius: 10)
.frame(width: 120, height: 120)
.foregroundColor(.orange)
.overlay(Text("(\(xCoor), \(yCoor))"))
}
}
}
}
.border(Color.green)
}
.border(Color.blue)
.navigationBarTitle("Contents of ScrollView misplaced", displayMode: .inline)
.navigationBarItems(
leading: HStack(spacing: 16) {
Text("x:")
.bold()
Button(action: { if self.x > 0 { self.x -= 1 } }) {
Image(systemName: "minus.circle.fill")
.imageScale(.large)
}
Button(action: { self.x += 1 }) {
Image(systemName: "plus.circle.fill")
.imageScale(.large)
}
},
trailing: HStack(spacing: 16) {
Text("y:")
.bold()
Button(action: { if self.y > 0 { self.y -= 1 } }) {
Image(systemName: "minus.circle.fill")
.imageScale(.large)
}
Button(action: { self.y += 1 }) {
Image(systemName: "plus.circle.fill")
.imageScale(.large)
}
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

Some workaround is to separate scroll view for vertical and horizontal axis.
import SwiftUI
struct Row: View {
var _y: Int
var x: Range<Int>
var body: some View {
HStack {
ForEach(x) { _x in
RoundedRectangle(cornerRadius: 10).tag(_x)
.frame(width: 120, height: 120)
.foregroundColor(.orange)
.overlay(Text("(\(_x), \(self._y))"))
}
}
}
}
struct Grid: View {
var m: Int
var n: Int
var body: some View {
ScrollView(.horizontal){
ScrollView(.vertical) {
ForEach(0 ..< n) { _y in
Row(_y: _y, x: 0 ..< self.m)
}
}
}
}
}
struct ContentView: View {
var body: some View {
VStack {
Text("Grid").font(.title)
Grid(m: 5, n: 5)
}
}
}
It is still not perfect, but usable.
The best is to use only one scroll axis and fill the second direction
with the right number of cells.
I miss how to know and to be able to adjust scroll position. SwiftUI is in very early stage, Once it will be more reliable, be patient

Related

TabView Indicator doesn't move when page changes

When I scroll from one page to the other the horizontal indicator bar doesn't move, what would be the appropriate way to move it (with animation if possible)?
The green area below doesn't move to the right once I switch to the analytics area.
Here's the full code:
enum PortfolioTabBarOptions: Hashable, CaseIterable {
case balance, analytics
var menuString: String { String(describing: self) }
}
struct CustomPagerTabView: View {
#Binding var selectedTab: PortfolioTabBarOptions
#State var tabOffset: CGFloat = 0
var body: some View {
VStack {
HStack(alignment: .center) {
HStack(spacing: 100) {
ForEach(Array(PortfolioTabBarOptions.allCases.enumerated()), id: \.element) { index, element in
// Current Tab
if(selectedTab.menuString == element.menuString) {
Text(element.menuString.capitalizeFirstLetter())
.font(.system(size: 15.0))
.bold()
.onTapGesture() {
selectedTab = element.self
}
.onAppear {
self.tabOffset = CGFloat(index)
}
}
// Innactive Tabs
else {
Text(element.menuString.capitalizeFirstLetter())
.foregroundColor(.gray)
.font(.system(size: 15.0))
.bold()
.onTapGesture() {
selectedTab = element.self
}
}
}
}
.padding(.horizontal)
}
// Indicator...
Capsule()
.fill(.gray)
// Width of the indicator bar
.frame(width: PortfolioTabBarOptions.allCases.count == 0 ? 0 : (getScreenBounds().width / CGFloat(PortfolioTabBarOptions.allCases.count)), height: 1.2)
.padding(.top,10)
.frame(maxWidth: .infinity,alignment: .leading)
.offset(x: tabOffset)
}
.padding(.top)
}
}
The green area below does move to the right, but only 1 pixel. Try something like this example code, choose the value (200) most suited for your purpose:
.onAppear {
self.tabOffset = CGFloat(index*200) // <-- here
}

How to have a custom alignment guide permeate a ScrollView in SwiftUI

I want to be able to align things in a ZStack. Some of these things are in a ScrollView in the ZStack, and some are not. I can get the things that are in the ScrollView to align with each other, and things outside the ScrollView to align with each other, but I cannot get them to all align. I can do if I just remove the ScrollView, so for instance:
extension VerticalAlignment {
enum MidAccountAndName: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[.top]
}
}
static let midAccountAndName = VerticalAlignment(MidAccountAndName.self)
}
struct ContentView: View {
var body: some View {
ZStack(alignment: Alignment(horizontal: .center, vertical: .midAccountAndName)) {
Rectangle()
.alignmentGuide(.midAccountAndName) { d in
d[VerticalAlignment.center]
}
.frame(width: 1000, height: 2)
HStack(alignment: .midAccountAndName) {
VStack {
Text("Left-hand text")
.alignmentGuide(.midAccountAndName) { d in
d[VerticalAlignment.center]
}
Text("More text")
}
VStack {
Text("Right-hand text")
Text("Title")
.alignmentGuide(.midAccountAndName) { d in
d[VerticalAlignment.center]
}
.font(.largeTitle)
}
}
}
}
}
Results in this, which is what I want:
But this code:
extension VerticalAlignment {
enum MidAccountAndName: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[.top]
}
}
static let midAccountAndName = VerticalAlignment(MidAccountAndName.self)
}
struct ContentView: View {
var body: some View {
ZStack(alignment: Alignment(horizontal: .center, vertical: .midAccountAndName)) {
Rectangle()
.alignmentGuide(.midAccountAndName) { d in
d[VerticalAlignment.center]
}
.frame(width: 1000, height: 2)
ScrollView {
HStack(alignment: .midAccountAndName) {
VStack {
Text("Left-hand text")
.alignmentGuide(.midAccountAndName) { d in
d[VerticalAlignment.center]
}
Text("More text")
}
VStack {
Text("Right-hand text")
Text("Title")
.alignmentGuide(.midAccountAndName) { d in
d[VerticalAlignment.center]
}
.font(.largeTitle)
}
}
}
}
}
}
Results in this, which is undesirable:
Can anyone help me get the former behaviour working in concert with the ScrollView?
How about this?
struct ContentView: View {
let largeFont = Font.largeTitle
let smallFont = Font.body
var body: some View {
HStack {
HStack {
VStack {
Text("|")
.font(largeFont)
.frame(width: 0)
Text("Left-hand text")
Text("More Text")
}
.font(smallFont)
}
HStack {
VStack {
Text("Right-hand text")
HStack {
Text("Title")
.font(largeFont)
}
}
.font(smallFont)
}
}
}
}
The trick is to use a placeholder at width: 0 on the other side of the HStack in your VStacks. I made the font variables so that you could change the relative sizes without accidentally messing up the alignment. There is usually a simple way in SwiftUI to achieve your layout, and let the OS do the work for you.

Animating a flashing bell in SwiftUI

I am having problems with making a simple systemIcon flash in SwiftUI.
I got the animation working, but it has a silly behaviour if the layout of
a LazyGridView changes or adapts. Below is a video of its erroneous behaviour.
The flashing bell stays in place but when the layout rearranges the bell
starts transitioning in from the bottom of the parent view thats not there anymore.
Has someone got a suggestion how to get around this?
Here is a working example which is similar to my problem
import SwiftUI
struct FlashingBellLazyVGrid: View {
#State var isAnimating = false
#State var showChart = true
var body: some View {
let columns = [GridItem(.adaptive(minimum: 300), spacing: 50, alignment: .center)]
VStack {
Button(action: {
showChart.toggle()
}) {
VStack {
Circle()
.fill(showChart ? Color.green : Color.red)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
Text("Charts")
.foregroundColor(Color.primary)
}.frame(width: 150, height: 50)
}
ScrollView {
LazyVGrid (
columns: columns, spacing: 50
) {
ForEach(0 ..< 25) { item in
ZStack {
Rectangle()
.fill(Color.red)
.cornerRadius(15)
VStack {
HStack {
Text(/*#START_MENU_TOKEN#*/"Hello, World!"/*#END_MENU_TOKEN#*/)
Spacer()
Image(systemName: "bell.fill")
.foregroundColor(Color.yellow)
.opacity(self.isAnimating ? 1 : 0)
.animation(Animation.easeInOut(duration: 0.66).repeatForever(autoreverses: false))
.onAppear{ self.isAnimating = true }
}.padding(50)
if showChart {
Rectangle()
.fill(Color.green)
.frame(height: 200)
}
}
}
}
}
}
}
}
}
struct FlashingBellLazyVGrid_Previews: PreviewProvider {
static var previews: some View {
FlashingBellLazyVGrid()
}
}
how it looks like before you click the showChart button at the top
After you toggle the button it looks like the bells are erroneously moving into place from the bottom of the screen. and toggling it back to its original state doesn't resolve this bug subsequently.
[
Looks like the animation is basing itself off of the original size of the view. In order to trick it into recognizing the new view size, I used .id(UUID()) on the outside of the grid. In a real world application, you'd probably want to be careful to store this ID somewhere and only refresh it when needed -- not on every re-render like I'm doing:
struct FlashingBellLazyVGrid: View {
#State var showChart = true
let columns = [GridItem(.adaptive(minimum: 300), spacing: 50, alignment: .center)]
var body: some View {
VStack {
Button(action: {
showChart.toggle()
}) {
VStack {
Circle()
.fill(showChart ? Color.green : Color.red)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
Text("Charts")
.foregroundColor(Color.primary)
}.frame(width: 150, height: 50)
}
ScrollView {
LazyVGrid (
columns: columns, spacing: 50
) {
ForEach(0 ..< 25) { item in
ZStack {
Rectangle()
.fill(Color.red)
.cornerRadius(15)
VStack {
SeparateComponent()
if showChart {
Rectangle()
.fill(Color.green)
.frame(height: 200)
}
}
}
}
}
.id(UUID()) //<-- Here
}
}
}
}
struct SeparateComponent : View {
#State var isAnimating : Bool = false
var body: some View {
HStack {
Text("Hello, World!")
Spacer()
Image(systemName: "bell.fill")
.foregroundColor(Color.yellow)
.opacity(self.isAnimating ? 1 : 0)
.animation(Animation.easeInOut(duration: 0.66).repeatForever(autoreverses: false))
.onAppear{
self.isAnimating = true
}
}
.padding(50)
}
}
I also separated out the blinking component into its own view, since there were already problematic things happening with the existing logic with onAppear, which wouldn't affect newly-scrolled-to items correctly. This may need refactoring for your particular case as well, but this should get you started.

Adding Segmented Style Picker to SwiftUI's NavigationView

The question is as simple as in the title. I am trying to put a Picker which has the style of SegmentedPickerStyle to NavigationBar in SwiftUI. It is just like the native Phone application's history page. The image is below
I have looked for Google and Github for example projects, libraries or any tutorials and no luck. I think if nativa apps and WhatsApp for example has it, then it should be possible. Any help would be appreciated.
SwiftUI 2 + toolbar:
struct DemoView: View {
#State private var mode: Int = 0
var body: some View {
Text("Hello, World!")
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Color", selection: $mode) {
Text("Light").tag(0)
Text("Dark").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
}
}
}
}
You can put a Picker directly into .navigationBarItems.
The only trouble I'm having is getting the Picker to be centered. (Just to show that a Picker can indeed be in the Navigation Bar I put together a kind of hacky solution with frame and Geometry Reader. You'll need to find a proper solution to centering.)
struct ContentView: View {
#State private var choices = ["All", "Missed"]
#State private var choice = 0
#State private var contacts = [("Anna Lisa Moreno", "9:40 AM"), ("Justin Shumaker", "9:35 AM")]
var body: some View {
GeometryReader { geometry in
NavigationView {
List {
ForEach(self.contacts, id: \.self.0) { (contact, time) in
ContactView(name: contact, time: time)
}
.onDelete(perform: self.deleteItems)
}
.navigationBarTitle("Recents")
.navigationBarItems(
leading:
HStack {
Button("Clear") {
// do stuff
}
Picker(selection: self.$choice, label: Text("Pick One")) {
ForEach(0 ..< self.choices.count) {
Text(self.choices[$0])
}
}
.frame(width: 130)
.pickerStyle(SegmentedPickerStyle())
.padding(.leading, (geometry.size.width / 2.0) - 130)
},
trailing: EditButton())
}
}
}
func deleteItems(at offsets: IndexSet) {
contacts.remove(atOffsets: offsets)
}
}
struct ContactView: View {
var name: String
var time: String
var body: some View {
HStack {
VStack {
Image(systemName: "phone.fill.arrow.up.right")
.font(.headline)
.foregroundColor(.secondary)
Text("")
}
VStack(alignment: .leading) {
Text(self.name)
.font(.headline)
Text("iPhone")
.foregroundColor(.secondary)
}
Spacer()
Text(self.time)
.foregroundColor(.secondary)
}
}
}
For those who want to make it dead center, Just put two HStack to each side and made them width fixed and equal.
Add this method to View extension.
extension View {
func navigationBarItems<L, C, T>(leading: L, center: C, trailing: T) -> some View where L: View, C: View, T: View {
self.navigationBarItems(leading:
HStack{
HStack {
leading
}
.frame(width: 60, alignment: .leading)
Spacer()
HStack {
center
}
.frame(width: 300, alignment: .center)
Spacer()
HStack {
//Text("asdasd")
trailing
}
//.background(Color.blue)
.frame(width: 100, alignment: .trailing)
}
//.background(Color.yellow)
.frame(width: UIScreen.main.bounds.size.width-32)
)
}
}
Now you have a View modifier which has the same usage of navigationBatItems(:_). You can edit the code based on your needs.
Usage example:
.navigationBarItems(leading: EmptyView(), center:
Picker(selection: self.$choice, label: Text("Pick One")) {
ForEach(0 ..< self.choices.count) {
Text(self.choices[$0])
}
}
.pickerStyle(SegmentedPickerStyle())
}, trailing: EmptyView())
UPDATE
There was the issue of leading and trailing items were violating UINavigationBarContentView's safeArea. While I was searching through, I came across another solution in this answer. It is little helper library called SwiftUIX. If you do not want install whole library -like me- I created a gist just for navigationBarItems. Just add the file to your project.
But do not forget this, It was stretching the Picker to cover all the free space and forcing StatusView to be narrower. So I had to set frames like this;
.navigationBarItems(center:
Picker(...) {
...
}
.frame(width: 150)
, trailing:
StatusView()
.frame(width: 70)
)
If you need segmentcontroll to be in center you need to use GeometryReader, below code will provide picker as title, and trailing (right) button.
You set up two view on the sides left and right with the same width, and the middle view will take the rest.
5 is the magic number depends how width you need segment to be.
You can experiment and see the best fit for you.
GeometryReader {
Text("TEST")
.navigationBarItems(leading:
HStack {
Spacer().frame(width: geometry.size.width / 5)
Spacer()
picker
Spacer()
Button().frame(width: geometry.size.width / 5)
}.frame(width: geometry.size.width)
}
But better solution is if you save picker size and then calculate other frame sizes, so picker will be same on ipad & iphone
#State var segmentControllerWidth: CGFloat = 0
var body: some View {
HStack {
Spacer()
.frame(width: (geometry.size.width / 2) - (segmentControllerWidth / 2))
.background(Color.red)
segmentController
.fixedSize()
.background(PreferenceViewSetter())
profileButton
.frame(width: (geometry.size.width / 2) - (segmentControllerWidth / 2))
}
.onPreferenceChange(PreferenceViewKey.self) { preferences in
segmentControllerWidth = preferences.width
}
}
struct PreferenceViewSetter: View {
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: PreferenceViewKey.self,
value: PreferenceViewData(width: geometry.size.width))
}
}
}
struct PreferenceViewData: Equatable {
let width: CGFloat
}
struct PreferenceViewKey: PreferenceKey {
typealias Value = PreferenceViewData
static var defaultValue = PreferenceViewData(width: 0)
static func reduce(value: inout PreferenceViewData, nextValue: () -> PreferenceViewData) {
value = nextValue()
}
}
Simple answer how to center segment controller and hide one of the buttons.
#State var showLeadingButton = true
var body: some View {
HStack {
Button(action: {}, label: {"leading"})
.opacity(showLeadingButton ? true : false)
Spacer()
Picker(selection: $selectedStatus,
label: Text("SEGMENT") {
segmentValues
}
.id(UUID())
.pickerStyle(SegmentedPickerStyle())
.fixedSize()
Spacer()
Button(action: {}, label: {"trailing"})
}
.frame(width: UIScreen.main.bounds.width)
}

SwiftUI create image slider with dots as indicators

I want to create a scroll view/slider for images. See my example code:
ScrollView(.horizontal, showsIndicators: true) {
HStack {
Image(shelter.background)
.resizable()
.frame(width: UIScreen.main.bounds.width, height: 300)
Image("pacific")
.resizable()
.frame(width: UIScreen.main.bounds.width, height: 300)
}
}
Though this enables the user to slide, I want it a little different (similar to a PageViewController in UIKit). I want it to behave like the typical image slider we know from a lot of apps with dots as indicators:
It shall always show a full image, no in between - hence if the user drags and stops in the middle, it shall automatically jump to the full image.
I want dots as indicators.
Since I've seen a lot of apps use such a slider, there must be known method, right?
There is no built-in method for this in SwiftUI this year. I'm sure a system-standard implementation will come along in the future.
In the short term, you have two options. As Asperi noted, Apple's own tutorials have a section on wrapping the PageViewController from UIKit for use in SwiftUI (see Interfacing with UIKit).
The second option is to roll your own. It's entirely possible to make something similar in SwiftUI. Here's a proof of concept, where the index can be changed by swipe or by binding:
struct PagingView<Content>: View where Content: View {
#Binding var index: Int
let maxIndex: Int
let content: () -> Content
#State private var offset = CGFloat.zero
#State private var dragging = false
init(index: Binding<Int>, maxIndex: Int, #ViewBuilder content: #escaping () -> Content) {
self._index = index
self.maxIndex = maxIndex
self.content = content
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
self.content()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
}
.content.offset(x: self.offset(in: geometry), y: 0)
.frame(width: geometry.size.width, alignment: .leading)
.gesture(
DragGesture().onChanged { value in
self.dragging = true
self.offset = -CGFloat(self.index) * geometry.size.width + value.translation.width
}
.onEnded { value in
let predictedEndOffset = -CGFloat(self.index) * geometry.size.width + value.predictedEndTranslation.width
let predictedIndex = Int(round(predictedEndOffset / -geometry.size.width))
self.index = self.clampedIndex(from: predictedIndex)
withAnimation(.easeOut) {
self.dragging = false
}
}
)
}
.clipped()
PageControl(index: $index, maxIndex: maxIndex)
}
}
func offset(in geometry: GeometryProxy) -> CGFloat {
if self.dragging {
return max(min(self.offset, 0), -CGFloat(self.maxIndex) * geometry.size.width)
} else {
return -CGFloat(self.index) * geometry.size.width
}
}
func clampedIndex(from predictedIndex: Int) -> Int {
let newIndex = min(max(predictedIndex, self.index - 1), self.index + 1)
guard newIndex >= 0 else { return 0 }
guard newIndex <= maxIndex else { return maxIndex }
return newIndex
}
}
struct PageControl: View {
#Binding var index: Int
let maxIndex: Int
var body: some View {
HStack(spacing: 8) {
ForEach(0...maxIndex, id: \.self) { index in
Circle()
.fill(index == self.index ? Color.white : Color.gray)
.frame(width: 8, height: 8)
}
}
.padding(15)
}
}
and a demo
struct ContentView: View {
#State var index = 0
var images = ["10-12", "10-13", "10-14", "10-15"]
var body: some View {
VStack(spacing: 20) {
PagingView(index: $index.animation(), maxIndex: images.count - 1) {
ForEach(self.images, id: \.self) { imageName in
Image(imageName)
.resizable()
.scaledToFill()
}
}
.aspectRatio(4/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
PagingView(index: $index.animation(), maxIndex: images.count - 1) {
ForEach(self.images, id: \.self) { imageName in
Image(imageName)
.resizable()
.scaledToFill()
}
}
.aspectRatio(3/4, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
Stepper("Index: \(index)", value: $index.animation(.easeInOut), in: 0...images.count-1)
.font(Font.body.monospacedDigit())
}
.padding()
}
}
Two notes:
The GIF animation does a really poor job of showing how smooth the animation is, as I had to drop the framerate and compress heavily due to file size limits. It looks great on simulator or a real device
The drag gesture in the simulator feels clunky, but it works really well on a physical device.
You can easily achieve this by below code
struct ContentView: View {
public let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#State private var selection = 0
/// images with these names are placed in my assets
let images = ["1","2","3","4","5"]
var body: some View {
ZStack{
Color.black
TabView(selection : $selection){
ForEach(0..<5){ i in
Image("\(images[i])")
.resizable()
.aspectRatio(contentMode: .fit)
}
}.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
.onReceive(timer, perform: { _ in
withAnimation{
print("selection is",selection)
selection = selection < 5 ? selection + 1 : 0
}
})
}
}
}