Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 🎸 [JIRA: HCPSDKFIORIUIKIT-2708] avatar stack support #788

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Apps/Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
B1DD86512B07534D00D7EDFD /* NavigationBarFioriStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DD86502B07534D00D7EDFD /* NavigationBarFioriStyle.swift */; };
B1DD86532B0758F000D7EDFD /* NavigationBarPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DD86522B0758F000D7EDFD /* NavigationBarPopover.swift */; };
B1DD86552B0759DD00D7EDFD /* NavigationBarCustomItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DD86542B0759DD00D7EDFD /* NavigationBarCustomItem.swift */; };
B1F384322C815A540090A858 /* AvatarStackExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F384312C815A540090A858 /* AvatarStackExample.swift */; };
B1F6FC302B22BDDA005190F9 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F6FC2F2B22BDDA005190F9 /* ToolbarView.swift */; };
B80DA9BA260BBF8600C0B2E9 /* SingleActionProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80DA9B9260BBF8600C0B2E9 /* SingleActionProfiles.swift */; };
B80DA9BC260BED9400C0B2E9 /* SingleActionCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80DA9BB260BED9400C0B2E9 /* SingleActionCollectionView.swift */; };
Expand Down Expand Up @@ -338,6 +339,7 @@
B1DD86502B07534D00D7EDFD /* NavigationBarFioriStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarFioriStyle.swift; sourceTree = "<group>"; };
B1DD86522B0758F000D7EDFD /* NavigationBarPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPopover.swift; sourceTree = "<group>"; };
B1DD86542B0759DD00D7EDFD /* NavigationBarCustomItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarCustomItem.swift; sourceTree = "<group>"; };
B1F384312C815A540090A858 /* AvatarStackExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackExample.swift; sourceTree = "<group>"; };
B1F6FC2F2B22BDDA005190F9 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = "<group>"; };
B80DA9B9260BBF8600C0B2E9 /* SingleActionProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleActionProfiles.swift; sourceTree = "<group>"; };
B80DA9BB260BED9400C0B2E9 /* SingleActionCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleActionCollectionView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -608,6 +610,7 @@
8A5579C824C1293C0098003A /* FioriSwiftUICore */ = {
isa = PBXGroup;
children = (
B1F384302C815A410090A858 /* AvatarStack */,
8732C2C32C35092D002110E9 /* Timeline */,
B19006582C201BAC000C8B10 /* ProfileHeader */,
1F1A1FF82C0BDA42007109D8 /* MenuSelection */,
Expand Down Expand Up @@ -806,6 +809,14 @@
path = Picker;
sourceTree = "<group>";
};
B1F384302C815A410090A858 /* AvatarStack */ = {
isa = PBXGroup;
children = (
B1F384312C815A540090A858 /* AvatarStackExample.swift */,
);
path = AvatarStack;
sourceTree = "<group>";
};
B80DA9C32612A54E00C0B2E9 /* Onboarding */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1165,6 +1176,7 @@
8A557A2224C12C9B0098003A /* CoreContentView.swift in Sources */,
8A5579D224C1293C0098003A /* Color+Extensions.swift in Sources */,
6D6E866F2C539CDE00EDB6F4 /* InPlaceLoadingFlexibleButtonExample.swift in Sources */,
B1F384322C815A540090A858 /* AvatarStackExample.swift in Sources */,
B1BCB6E12C2EB362008AC070 /* ProfileHeaderStaticExample.swift in Sources */,
C1C764882A818BEC00BCB0F7 /* SortFilterExample.swift in Sources */,
64905D072C6D13E20062AAD4 /* SwitchExample.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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)
.avatarsBorder(self.model.borderColor, width: 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 = 1
}

#Preview {
AvatarStackExample()
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ struct CoreContentView: View {
var body: some View {
List {
Section(header: Text("Views")) {
NavigationLink(
destination: AvatarStackExample(),
label: {
Text("AvatarStack")
}
)

NavigationLink(
destination: FioriButtonContentView(),
label: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Tag("103-Repair")
})
.isAvatarCircular(false)
.avatarBorderWidth(2)
.avatarBorderColor(Color.yellow)
.avatarsBorder(Color.yellow, width: 2)
.avatarSize(CGSize(width: 40, height: 40))
.splitPercent(0.5)
.footnoteIconsMaxCount(-2)
Expand Down Expand Up @@ -136,8 +135,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Color.blue
})
.isAvatarCircular(true)
.avatarBorderWidth(1)
.avatarBorderColor(Color.black)
.avatarsBorder(Color.black)
.splitPercent(0.5)
.footnoteIconsSpacing(2)
return AnyView(oi)
Expand Down Expand Up @@ -184,8 +182,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Color.blue
})
.isAvatarCircular(true)
.avatarBorderWidth(3)
.avatarBorderColor(Color.blue)
.avatarsBorder(Color.blue, width: 3)
.avatarSize(CGSize(width: 30, height: 30))
.footnoteIconsMaxCount(4)
.footnoteIconsSize(CGSize(width: 20, height: 20))
Expand Down Expand Up @@ -334,8 +331,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Tag("103-Repair")
})
.isAvatarCircular(false)
.avatarBorderWidth(2)
.avatarBorderColor(Color.yellow)
.avatarsBorder(Color.yellow, width: 2)
.avatarSize(CGSize(width: 40, height: 40))
.splitPercent(0.5)
.footnoteIconsMaxCount(-2)
Expand Down Expand Up @@ -372,8 +368,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Color.blue
})
.isAvatarCircular(true)
.avatarBorderWidth(1)
.avatarBorderColor(Color.black)
.avatarsBorder(Color.black)
.splitPercent(0.5)
.footnoteIconsSpacing(2)
return AnyView(oi)
Expand Down Expand Up @@ -420,8 +415,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Color.blue
})
.isAvatarCircular(true)
.avatarBorderWidth(3)
.avatarBorderColor(Color.blue)
.avatarsBorder(Color.blue, width: 3)
.avatarSize(CGSize(width: 30, height: 30))
.footnoteIconsMaxCount(4)
.footnoteIconsSize(CGSize(width: 20, height: 20))
Expand Down
7 changes: 0 additions & 7 deletions Sources/FioriSwiftUICore/Models/ModelDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,6 @@ import SwiftUI
// sourcery: add_env_props = "numberOfLines"
public protocol IconStackModel: IconsComponent {}

// sourcery: generated_component_not_configurable
// sourcery: add_env_props = "avatarSize"
// sourcery: add_env_props = "isAvatarCircular"
// sourcery: add_env_props = "avatarBorderWidth"
// sourcery: add_env_props = "avatarBorderColor"
public protocol AvatarStackModel: AvatarsComponent {}

// sourcery: generated_component_not_configurable
// sourcery: add_env_props = "footnoteIconsSize"
// sourcery: add_env_props = "footnoteIconsSpacing"
Expand Down
127 changes: 127 additions & 0 deletions Sources/FioriSwiftUICore/Views/CustomBuilder/AvatarListView.swift
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 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty Count Violation: Prefer checking isEmpty over comparing count to zero. (empty_count)

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))
}
}
}
}
}
Loading
Loading