-
Notifications
You must be signed in to change notification settings - Fork 57
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: 🎸 [JIRA: HCPSDKFIORIUIKIT-2708] avatar stack support
- Loading branch information
1 parent
1acd47c
commit b854caa
Showing
27 changed files
with
1,062 additions
and
359 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
113 changes: 113 additions & 0 deletions
113
Apps/Examples/Examples/FioriSwiftUICore/AvatarStack/AvatarStackExample.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import Combine | ||
import FioriSwiftUICore | ||
import FioriThemeManager | ||
import Foundation | ||
import SwiftUI | ||
|
||
struct AvatarStackExample: View { | ||
@StateObject var model = AvatarStackModel() | ||
|
||
@ViewBuilder var avatarStack: some View { | ||
AvatarStack { | ||
ForEach(0 ..< self.model.avatarsCount, id: \.self) { _ in | ||
Color.random | ||
} | ||
} avatarsTitle: { | ||
if self.model.title.isEmpty { | ||
EmptyView() | ||
} else { | ||
Text(self.model.title) | ||
} | ||
} | ||
.avatarsLayout(self.model.avatarsLayout) | ||
.isAvatarCircular(self.model.isCircular) | ||
.avatarsTitlePosition(self.model.titlePosition) | ||
.avatarsSpacing(self.model.spacing) | ||
.avatarsMaxCount(self.model.maxCount) | ||
.avatarBorderColor(self.model.borderColor) | ||
.avatarBorderWidth(self.model.borderWidth) | ||
.avatarSize(self.avatarSize) | ||
} | ||
|
||
var avatarSize: CGSize? { | ||
if let sideLength = model.sideLength { | ||
CGSize(width: sideLength, height: sideLength) | ||
} else { | ||
nil | ||
} | ||
} | ||
|
||
var body: some View { | ||
List { | ||
Section { | ||
self.avatarStack | ||
} | ||
|
||
Picker("Avatar Count", selection: self.$model.avatarsCount) { | ||
ForEach(0 ... 20, id: \.self) { number in | ||
Text("\(number)").tag(number) | ||
} | ||
} | ||
TextField("Enter Title", text: self.$model.title) | ||
Toggle("isCircle", isOn: self.$model.isCircular) | ||
|
||
Picker("Avatars Layout", selection: self.$model.avatarsLayout) { | ||
Text("grouped").tag(AvatarStack.Layout.grouped) | ||
Text("horizontal").tag(AvatarStack.Layout.horizontal) | ||
} | ||
Picker("Title Position", selection: self.$model.titlePosition) { | ||
Text("leading").tag(AvatarStack.TextPosition.leading) | ||
Text("trailing").tag(AvatarStack.TextPosition.trailing) | ||
Text("top").tag(AvatarStack.TextPosition.top) | ||
Text("bottom").tag(AvatarStack.TextPosition.bottom) | ||
} | ||
|
||
Picker("Spacing (only work for horizontal avatars)", selection: self.$model.spacing) { | ||
ForEach([-4, -1, 0, 1, 4], id: \.self) { number in | ||
Text("\(number)").tag(CGFloat(number)) | ||
} | ||
} | ||
|
||
Picker("Max Count", selection: self.$model.maxCount) { | ||
Text("None").tag(nil as Int?) | ||
ForEach([2, 4, 8], id: \.self) { number in | ||
Text("\(number)").tag(number as Int?) | ||
} | ||
} | ||
|
||
Picker("Side Length", selection: self.$model.sideLength) { | ||
Text("Default").tag(nil as CGFloat?) | ||
ForEach([10, 16, 20, 30, 40], id: \.self) { number in | ||
Text("\(number)").tag(CGFloat(number) as CGFloat?) | ||
} | ||
} | ||
|
||
Picker("Border Width", selection: self.$model.borderWidth) { | ||
ForEach([0, 1, 2, 4], id: \.self) { number in | ||
Text("\(number)").tag(CGFloat(number)) | ||
} | ||
} | ||
|
||
ColorPicker(selection: self.$model.borderColor, supportsOpacity: false) { | ||
Text("Border Color") | ||
} | ||
} | ||
} | ||
} | ||
|
||
class AvatarStackModel: ObservableObject { | ||
@Published var avatarsCount: Int = 2 | ||
@Published var title: String = "This is a text for avatar stack." | ||
@Published var isCircular: Bool = true | ||
@Published var avatarsLayout: AvatarStack.Layout = .grouped | ||
@Published var titlePosition: AvatarStack.TextPosition = .trailing | ||
@Published var spacing: CGFloat = -1 | ||
@Published var maxCount: Int? = nil | ||
@Published var sideLength: CGFloat? = nil | ||
@Published var borderColor = Color.clear | ||
@Published var borderWidth: CGFloat = 0 | ||
} | ||
|
||
#Preview { | ||
AvatarStackExample() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
127 changes: 127 additions & 0 deletions
127
Sources/FioriSwiftUICore/Views/CustomBuilder/AvatarListView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import SwiftUI | ||
|
||
struct SingleAvatar: View, AvatarList { | ||
var count: Int { | ||
self.isEmpty ? 0 : 1 | ||
} | ||
|
||
func view(at index: Int) -> some View { | ||
self | ||
} | ||
|
||
@Environment(\.avatarBorderColor) var borderColor | ||
@Environment(\.avatarBorderWidth) var borderWidth | ||
@Environment(\.isAvatarCircular) var isCircular | ||
@Environment(\.avatarSize) var avatarSize | ||
@Environment(\.avatarsLayout) var layout | ||
|
||
var size: CGSize { | ||
if let size = avatarSize { | ||
return size | ||
} else { | ||
switch self.layout { | ||
case .grouped: | ||
return CGSize(width: 45, height: 45) | ||
case .horizontal: | ||
return CGSize(width: 16, height: 16) | ||
} | ||
} | ||
} | ||
|
||
let avatar: any View | ||
|
||
var body: some View { | ||
if self.isCircular { | ||
self.avatar.typeErased | ||
.frame(width: self.size.width, height: self.size.height) | ||
.clipShape(Capsule()) | ||
.overlay { | ||
Capsule() | ||
.inset(by: self.borderWidth / 2.0) | ||
.stroke(self.borderColor, lineWidth: self.borderWidth) | ||
} | ||
} else { | ||
self.avatar.typeErased | ||
.frame(width: self.size.width, height: self.size.height) | ||
.border(self.borderColor, width: self.borderWidth) | ||
} | ||
} | ||
} | ||
|
||
struct AvatarListView<T: AvatarList>: View { | ||
@Environment(\.avatarsLayout) var layout | ||
@Environment(\.avatarsMaxCount) var maxCount | ||
@Environment(\.avatarsSpacing) var spacing | ||
@Environment(\.avatarSize) var avatarSize | ||
let avatars: T | ||
|
||
var size: CGSize { | ||
if let size = avatarSize { | ||
return size | ||
} else { | ||
switch self.layout { | ||
case .grouped: | ||
return CGSize(width: 45, height: 45) | ||
case .horizontal: | ||
return CGSize(width: 16, height: 16) | ||
} | ||
} | ||
} | ||
|
||
var count: Int { | ||
self.avatars.count | ||
} | ||
|
||
// This condition check if for handle recursive builder issue. | ||
private func checkIfIsNestingAvatars() -> Bool { | ||
if self.count == 1 { | ||
let typeString = String(describing: avatars.view(at: 0).self) | ||
return typeString.contains("AvatarsListStack") | ||
} else { | ||
return false | ||
} | ||
} | ||
|
||
/// :nodoc: | ||
var body: some View { | ||
if self.count == 0 { | ||
EmptyView() | ||
} else if self.count == 1, self.checkIfIsNestingAvatars() { | ||
self.avatars.view(at: 0) | ||
} else { | ||
self.buildAvatars() | ||
} | ||
} | ||
|
||
@ViewBuilder func buildAvatars() -> some View { | ||
switch self.layout { | ||
case .grouped: | ||
// Currently group avatars support 2 avatars default. | ||
let count = min(avatars.count, self.maxCount ?? 2) | ||
if count > 1 { | ||
ZStack(alignment: .topLeading) { | ||
ForEach(0 ..< count, id: \.self) { index in | ||
let position = CGPoint(x: CGFloat(index + 1) * self.size.width / 2, | ||
y: CGFloat(index + 1) * self.size.height / 2) | ||
SingleAvatar(avatar: self.avatars.view(at: index)) | ||
.position(position) | ||
} | ||
} | ||
.frame(width: self.size.width * (1 + CGFloat(count - 1) * 0.5), | ||
height: self.size.height * (1 + CGFloat(count - 1) * 0.5)) | ||
} else if count == 1 { | ||
SingleAvatar(avatar: self.avatars.view(at: 0)) | ||
} else { | ||
EmptyView() | ||
} | ||
case .horizontal: | ||
HorizontalIconsHStack(spacing: self.spacing) { | ||
let validMaxCount = self.maxCount ?? 0 | ||
let itemsCount = validMaxCount <= 0 ? self.count : min(self.count, validMaxCount) | ||
ForEach(0 ..< itemsCount, id: \.self) { index in | ||
SingleAvatar(avatar: self.avatars.view(at: index)) | ||
} | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.