Skip to content

Commit

Permalink
Merge pull request #48 from alstn38/S002/feat/#40
Browse files Browse the repository at this point in the history
feat: 스와이프와 버튼을 동기화 합니다.
  • Loading branch information
alstn38 authored Nov 14, 2024
2 parents bea6595 + 9604c08 commit 039d06e
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 29 deletions.
14 changes: 14 additions & 0 deletions Molio/Source/Presentation/Common/Component/CircleMenuButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import UIKit

final class CircleMenuButton: UIButton {

private var defaultBackgroundColor: UIColor
private var highlightBackgroundColor: UIColor

override var isHighlighted: Bool {
didSet {
self.backgroundColor = isHighlighted ? highlightBackgroundColor : defaultBackgroundColor
}
}

private let buttonImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
Expand All @@ -10,11 +19,14 @@ final class CircleMenuButton: UIButton {
}()

init(backgroundColor: UIColor,
highlightColor: UIColor? = nil,
buttonSize: CGFloat,
tintColor: UIColor?,
buttonImage: UIImage?,
buttonImageSize: CGSize
) {
self.defaultBackgroundColor = backgroundColor
self.highlightBackgroundColor = highlightColor ?? backgroundColor.withAlphaComponent(0.8)
super.init(frame: .zero)
self.translatesAutoresizingMaskIntoConstraints = false
setupView(backgroundColor: backgroundColor,
Expand All @@ -26,6 +38,8 @@ final class CircleMenuButton: UIButton {
}

required init?(coder: NSCoder) {
self.defaultBackgroundColor = .black.withAlphaComponent(0.51)
self.highlightBackgroundColor = defaultBackgroundColor.withAlphaComponent(0.8)
super.init(coder: coder)
}

Expand Down
134 changes: 105 additions & 29 deletions Molio/Source/Presentation/SwipeMusic/View/SwipeMusicViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ final class SwipeMusicViewController: UIViewController {
private var input: SwipeMusicViewModel.Input
private var output: SwipeMusicViewModel.Output
private let viewDidLoadPublisher = PassthroughSubject<Void, Never>()
private let musicCardDidChangeSwipePublisher = PassthroughSubject<CGFloat, Never>()
private let musicCardDidFinishSwipePublisher = PassthroughSubject<CGFloat, Never>()
private let likeButtonDidTapPublisher = PassthroughSubject<Void, Never>()
private let dislikeButtonDidTapPublisher = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
private let basicBackgroundColor = UIColor(resource: .background)
private var impactFeedBack = UIImpactFeedbackGenerator(style: .medium)
private var hasProvidedImpactFeedback: Bool = false

private let playlistSelectButton: UIButton = {
let button = UIButton()
Expand Down Expand Up @@ -47,33 +53,43 @@ final class SwipeMusicViewController: UIViewController {

private let musicCardView = MusicCardView()

private let filterButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.2),
private let filterButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.51),
highlightColor: .white.withAlphaComponent(0.51),
buttonSize: 58.0,
tintColor: .white,
buttonImage: UIImage(systemName: "slider.horizontal.3"),
buttonImageSize: CGSize(width: 21.0, height: 19.0))

private let dislikeButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.2),
private let dislikeButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.51),
highlightColor: .white.withAlphaComponent(0.51),
buttonSize: 66.0,
tintColor: UIColor(hex: "#FF3D3D"),
buttonImage: UIImage(systemName: "xmark"),
buttonImageSize: CGSize(width: 25.0, height: 29.0))

private let likeButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.2),
private let likeButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.51),
highlightColor: .white.withAlphaComponent(0.51),
buttonSize: 66.0,
tintColor: UIColor(resource: .main),
buttonImage: UIImage(systemName: "heart.fill"),
buttonImageSize: CGSize(width: 30.0, height: 29.0))

private let myMolioButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.2),
private let myMolioButton = CircleMenuButton(backgroundColor: .black.withAlphaComponent(0.51),
highlightColor: .white.withAlphaComponent(0.51),
buttonSize: 58.0,
tintColor: UIColor(hex: "#FFFAFA"),
buttonImage: UIImage(systemName: "music.note"),
buttonImageSize: CGSize(width: 18.0, height: 24.0))

init(viewModel: SwipeMusicViewModel) {
self.viewModel = viewModel
self.input = SwipeMusicViewModel.Input(viewDidLoad: viewDidLoadPublisher.eraseToAnyPublisher())
self.input = SwipeMusicViewModel.Input(
viewDidLoad: viewDidLoadPublisher.eraseToAnyPublisher(),
musicCardDidChangeSwipe: musicCardDidChangeSwipePublisher.eraseToAnyPublisher(),
musicCardDidFinishSwipe: musicCardDidFinishSwipePublisher.eraseToAnyPublisher(),
likeButtonDidTap: likeButtonDidTapPublisher.eraseToAnyPublisher(),
dislikeButtonDidTap: dislikeButtonDidTapPublisher.eraseToAnyPublisher()
)
self.output = viewModel.transform(from: input)
super.init(nibName: nil, bundle: nil)
}
Expand All @@ -97,7 +113,13 @@ final class SwipeMusicViewController: UIViewController {
fetchImageUseCase: defaultFetchImageUseCase
)
self.viewModel = swipeMusicViewModel
self.input = SwipeMusicViewModel.Input(viewDidLoad: viewDidLoadPublisher.eraseToAnyPublisher())
self.input = SwipeMusicViewModel.Input(
viewDidLoad: viewDidLoadPublisher.eraseToAnyPublisher(),
musicCardDidChangeSwipe: musicCardDidChangeSwipePublisher.eraseToAnyPublisher(),
musicCardDidFinishSwipe: musicCardDidFinishSwipePublisher.eraseToAnyPublisher(),
likeButtonDidTap: likeButtonDidTapPublisher.eraseToAnyPublisher(),
dislikeButtonDidTap: dislikeButtonDidTapPublisher.eraseToAnyPublisher()
)
self.output = viewModel.transform(from: input)
super.init(coder: coder)
}
Expand All @@ -111,6 +133,7 @@ final class SwipeMusicViewController: UIViewController {
setupMenuButtonView()

setupBindings()
setupButtonTarget()
addPanGestureToMusicTrack()

viewDidLoadPublisher.send()
Expand All @@ -126,44 +149,97 @@ final class SwipeMusicViewController: UIViewController {
view.backgroundColor = artworkBackgroundColor
musicCardView.configure(music: music)
}.store(in: &cancellables)

output.buttonHighlight
.receive(on: RunLoop.main)
.sink { [weak self] buttonHighlight in
guard let self else { return }
self.likeButton.isHighlighted = buttonHighlight.isLikeHighlighted
self.dislikeButton.isHighlighted = buttonHighlight.isDislikeHighlighted
}
.store(in: &cancellables)

output.musicCardSwipeAnimation
.receive(on: RunLoop.main)
.sink { [weak self] swipeDirection in
guard let self else { return }
self.animateMusicCard(direction: swipeDirection)
}
.store(in: &cancellables)
}

/// Swipe 동작이 끝나고 MusicCard가 animation되는 메서드
private func animateMusicCard(direction: SwipeMusicViewModel.SwipeDirection) {
let currentCenter = musicCardView.center
let viewCenter = view.center
let frameWidth = view.frame.width

switch direction {
case .left, .right:
let movedCenterX = currentCenter.x + direction.rawValue * frameWidth
UIView.animate(
withDuration: 0.3,
animations: { [weak self] in
self?.musicCardView.center = CGPoint(x: movedCenterX, y: currentCenter.y)
},
completion: { [weak self] _ in
self?.refreshMusicCardView()
})
case .none:
UIView.animate(withDuration: 0.3) { [weak self] in
self?.musicCardView.center = viewCenter
}
}
}

/// Music Card가 다시 refresh되는 메서드
private func refreshMusicCardView() {
self.musicCardView.removeFromSuperview()
self.setupMusicTrackView()
}

private func addPanGestureToMusicTrack() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
musicCardView.addGestureRecognizer(panGesture)
}

private func setupButtonTarget() {
likeButton.addTarget(self, action: #selector(didTapLikeButton), for: .touchUpInside)
dislikeButton.addTarget(self, action: #selector(didTapDislikeButton), for: .touchUpInside)
}

/// 사용자에게 진동 feedback을 주는 메서드
private func providedImpactFeedback(translationX: CGFloat) {
if abs(translationX) > viewModel.swipeThreshold && !hasProvidedImpactFeedback {
impactFeedBack.impactOccurred()
hasProvidedImpactFeedback = true
} else if abs(translationX) <= viewModel.swipeThreshold {
hasProvidedImpactFeedback = false
}
}

@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
guard let card = gesture.view else { return }

let translation = gesture.translation(in: view)
card.center = CGPoint(x: view.center.x + translation.x, y: view.center.y + translation.y)

if gesture.state == .ended {
// 스와이프 임계값 : 카드를 특정 거리 이상 스와이프되었는지를 확인한다.
let swipeThreshold: CGFloat = 200

// X축으로 이동한 거리가 스와이프 임계값을 넘은 경우
if abs(translation.x) > swipeThreshold {
let direction: CGFloat = translation.x > 0 ? 1 : -1 // 좌우 판단
// 화면 밖으로 이동하는 애니메이션
UIView.animate(withDuration: 0.3, animations: {
card.center = CGPoint(x: card.center.x + direction * self.view.frame.width, y: card.center.y)
}) { _ in
// 애니메이션 이후 카드 제거 및 새로운 카드 설정
card.removeFromSuperview()
self.setupMusicTrackView()
}
} else {
// 다시 원래 자리로 되돌린다.
UIView.animate(withDuration: 0.3) {
card.center = self.view.center
card.transform = .identity
}
}

if gesture.state == .changed {
musicCardDidChangeSwipePublisher.send(translation.x)
providedImpactFeedback(translationX: translation.x)
} else if gesture.state == .ended {
musicCardDidFinishSwipePublisher.send(translation.x)
}
}

@objc private func didTapLikeButton() {
likeButtonDidTapPublisher.send()
}

@objc private func didTapDislikeButton() {
dislikeButtonDidTapPublisher.send()
}

private func setupSelectPlaylistView() {
view.addSubview(playlistSelectButton)
view.addSubview(selectedPlaylistTitleLabel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,43 @@ import MusicKit
final class SwipeMusicViewModel: InputOutputViewModel {
struct Input {
let viewDidLoad: AnyPublisher<Void, Never>
let musicCardDidChangeSwipe: AnyPublisher<CGFloat, Never>
let musicCardDidFinishSwipe: AnyPublisher<CGFloat, Never>
let likeButtonDidTap: AnyPublisher<Void, Never>
let dislikeButtonDidTap: AnyPublisher<Void, Never>
}

struct Output {
let currentMusicTrack: AnyPublisher<SwipeMusicTrackModel, Never>
let isLoading: AnyPublisher<Bool, Never> // TODO: 로딩 UI 구현 및 연결
let buttonHighlight: AnyPublisher<ButtonHighlight, Never>
let musicCardSwipeAnimation: AnyPublisher<SwipeDirection, Never>
let error: AnyPublisher<String, Never> // TODO: Error에 따른 알림 UI 구현 및 연결
}

enum SwipeDirection: CGFloat {
case left = -1.0
case right = 1.0
case none = 0
}

struct ButtonHighlight {
let isLikeHighlighted: Bool
let isDislikeHighlighted: Bool
}

var swipeThreshold: CGFloat {
return 170.0
}

private let musicPlayer = SwipeMusicPlayer()
private let fetchMusicsUseCase: FetchMusicsUseCase
private let fetchImageUseCase: FetchImageUseCase
private var musicsSubject = CurrentValueSubject<[RandomMusic], Never>([])
private let currentMusicCardPublisher = PassthroughSubject<SwipeMusicTrackModel, Never>()
private let isLoadingPublisher = PassthroughSubject<Bool, Never>()
private let buttonHighlightPublisher = PassthroughSubject<ButtonHighlight, Never>()
private let musicCardSwipeAnimationPublisher = PassthroughSubject<SwipeDirection, Never>()
private let errorPublisher = PassthroughSubject<String, Never>()
private var cancellables = Set<AnyCancellable>()

Expand All @@ -43,8 +66,63 @@ final class SwipeMusicViewModel: InputOutputViewModel {
}
.store(in: &cancellables)

input.musicCardDidChangeSwipe
.map { [weak self] translation -> ButtonHighlight in
guard let self else {
return ButtonHighlight(isLikeHighlighted: false, isDislikeHighlighted: false)
}
return ButtonHighlight(isLikeHighlighted: translation > self.swipeThreshold,
isDislikeHighlighted: translation < -self.swipeThreshold
)
}
.sink { [weak self] buttonHighlight in
self?.buttonHighlightPublisher.send(buttonHighlight)
}
.store(in: &cancellables)

input.musicCardDidFinishSwipe
.map { [weak self] translation -> SwipeDirection in
guard let self else { return .none }
if translation > self.swipeThreshold {
// TODO: 노래 좋아요에 대한 처리 추가하기
return .right
} else if translation < -self.swipeThreshold {
// TODO: 노래 싫어요에 대한 처리 추가하기
return .left
} else {
return .none
}
}
.sink { [weak self] swipeDirection in
guard let self else { return }
self.musicCardSwipeAnimationPublisher.send(swipeDirection)
self.buttonHighlightPublisher.send(
ButtonHighlight(isLikeHighlighted: false,
isDislikeHighlighted: false)
)
}
.store(in: &cancellables)

input.likeButtonDidTap
.sink { [weak self] _ in
guard let self else { return }
// TODO: 노래 좋아요에 대한 처리 추가하기
self.musicCardSwipeAnimationPublisher.send(.right)
}
.store(in: &cancellables)

input.dislikeButtonDidTap
.sink { [weak self] _ in
guard let self else { return }
// TODO: 노래 싫어요 대한 처리 추가하기
self.musicCardSwipeAnimationPublisher.send(.left)
}
.store(in: &cancellables)

return Output(currentMusicTrack: currentMusicCardPublisher.eraseToAnyPublisher(),
isLoading: isLoadingPublisher.eraseToAnyPublisher(),
buttonHighlight: buttonHighlightPublisher.eraseToAnyPublisher(),
musicCardSwipeAnimation: musicCardSwipeAnimationPublisher.eraseToAnyPublisher(),
error: errorPublisher.eraseToAnyPublisher()
)
}
Expand Down

0 comments on commit 039d06e

Please sign in to comment.