# STEP 1
## 박스오피스
안녕하세요 올라프(@1Consumption)!
이번에 박스오피스 리뷰를 받게 된 Zion, Hemg 입니다.
2주간 잘 부탁드립니다.
## 고민했던 점
### 재사용될 수 있는 MovieInformation Model을 독립타입으로 분리
`MovieInformation Model`은 `DailyBoxOfiice` 이외의 다른 `Response를` 받을 때도 활용할 수 있도록 독립타입으로 분리했습니다.
현재 Step에서의 주어진 요구사항은 `DailyBoxOffice`에 대한 정보의 `Decoding`입니다. 하지만 Step에서 제시해준 영화진흥위원회의 오픈API를 확인해본 결과 '일별' 뿐만아니라 '주간/주말'에 대한 데이터도 존재할 수 있다는 것을 알게되었고 `MoveInformation Model`의 재사용성을 위해 독립 타입으로 분리하게 되었습니다.
### 디코딩 실패
```swift!
struct BoxOfficeResult: Decodable, Equatable {
let boxOfficeType: String
let showRange: String
let dailyBoxOfficeList: [DailyBoxOffice]
struct DailyBoxOffice: Decodable, Equatable {
.......
```
이렇게 모델을 디코딩 타입을 만들어 test를 진행하려고 했었는데 해당 모델 타입으로 디코딩하지 못하고 에러가 나타났었습니다.
`DailyBoxOffice` 변수명 혹은 `CodingKey`값의 오타가 있다고 생각하여 문제를 찾아보려고 진행을 했었으며 문제를 찾지 못했습니다.
다시 한번 JSON 파일을 확인했을 때 `boxOfficeResult`타입 안에 `DailBoxOffice` 타입이 존재하여 접근을 했어야 했습니다.
```swift!
struct BoxOfficeResult: Decodable, Equatable {
let boxOfficeResult: DailyBoxOffice
struct DailyBoxOffice: Decodable, Equatable {
let boxofficeType: String
let showRange: String
let dailyBoxOfficeList: [MovieInformation]
}
}
```
`BoxOfficeResult` 타입으로 감싼 뒤 접근을 진행하여 문제를 해결할 수 있었습니다.
---
# STEP 2
## 박스오피스
안녕하세요 올라프(@1Consumption)!
박스오피스 STEP2 구현을 진행했습니다.
부족하지만 잘부탁드립니다.
## 고민했던 점
### 범용적으로 사용될 수 있는 Network 로직만들기
- `BoxOffice`에서도 여러가지의 API들을 호출할 수 있도록 혹은 다른 `BaseURL`이 호출되더라도 정보를 받아올 수 있도록(GET) 네트워크로직을 설계했습니다.
현재 `NetworkKey`, `BaseURL`, `BoxOfficeURLPath`, `API~`등 여러가지 타입들을 둬서 앞으로의 여러가지 종류에 `API` 호출에 대응하고 `decoding`해 올바르게 `ResponseData`를 받아 올 수 있도록 만들었습니다.
### 네트워크 요청 실패를 알리기 위한 방법
- 인터넷 연결이 불안정한 상태에서 사용자에게 네트워크 요청이 실패했다는 것을 알려주는 팝업을 노출시키기로 했습니다.
네트워크 로직의 특성상 인터넷 연결이 불안정하다면 사용자가 원하는 데이터를 일정 시간내에 전달받지 못하는 경우가 존재합니다. 그렇게 되는 경우 사용자로 하여금 네트워크 요청이 실패했다는 것을 알려주는 팝업을 노출시키고 네트워킹을 재시도 할 수 있는 선택지를 두어 네트워크 연결을 재시도할 수 있게 설계했습니다.
----
### 코드 재사용성 늘리기
```swift
let url = "http://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json"
let apiKey = "f5eef3421c602c6cb7ea2241047958"
let targetDate = "20230722"
let currentURL = testURL(url: url, apiKey: apiKey, targetTitle: "targetDt", target: targetDate)
officeURL.dailyBoxOfficeSession(target: currentURL!)
func testURL(url: String, apiKey: String, targetTitle: String, target: String) -> URL?
```
```swift
let queryItems: [String: Any] = [
"key": NetworkKey.boxOffice,
"movieCd": "20218541"
]
let request = APIRequest(baseURL: BaseURL.boxOffice, path: BoxOfficeURLPath.movieDetail, queryItems: queryItems)
struct APIRequest {
let baseURL: String
let path: String?
let queryItems: [String: Any]?
}
```
- 데이터를 요청을 진행할때에 함수내에서 `URL`, `Key`, `TargetData` 등 요청인터페이스를 그때마다 설정하여 요청을 진행했는데 반복되는 코드가 생기는점이 있어 반복되는점을 수정하여 고정된 값은 재사용하며, 변경되어야할 값만 변경하여 요청할수있게 범용적으로 진행했습니다.
# STEP 3
## 박스오피스
안녕하세요 올라프(@1Consumption)!
박스오피스 STEP3 구현을 진행했습니다.
부족하지만 잘부탁드립니다.
## 고민했던 점
### global function에서의 순환참조
global function에서의 타입에 대한 참조는 reference Count를 증가시키지 않습니다.
> Global functions are closures that have a name and don’t capture any values.
왜냐하면 `global function`에서는 어떠한 값도 `capture`하지 않기 때문입니다.
`global capture`에서 값을 캡쳐하지 않아도 되는 이유는 클로저에서 값을 캡처하는 이유는 상수와 변수를 정의한 원래 범위가 더이상 존재하지 않더라도 클로저 내에서 해당 상수와 변수의 값을 참조하고 수정하기 위해서 캡처가 필요하지만, 전역에서 정의된 클로저의 경우 상수와 변수가 정의된 범위가 존재하지 않을 수 없습니다 따라서 `capture`가 필요하지 않습니다.
일반적으로 순환참조가 발생하는 경우는 해당 클로저를 특정 타입에서 참조하고 있고 그 클로저에서 타입을 캡쳐해서 참조하기 때문에 순환참조가 발생합니다. 캡쳐하는 순간 `Strong Reference`로 참조하기 때문입니다. 하지만 `global function`는 어떠한 값도 `capture`하지 않는 클로저 이므로 `reference count` 또한 증가시키지 않기 때문에 `[weak self]` 키워드를 붙이지 않아도 순환참조가 발생하지 않습니다.
<참고 자료>
[Closure docs](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures/)
[StackOverFlow](https://stackoverflow.com/questions/49239990/why-swift-global-functions-as-a-special-case-of-closures-capture-global-variable)
### 날짜 표시
```swift
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "ko_kr")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private var dateString: String {
let dataString = dateFormatter.string(from: Date())
let removeString = dataString.components(separatedBy: ["-"]).joined()
let change = String((Int(removeString) ?? 0) - 1)
return change
}
```
- 위와 같은 코드로 진행하여 Formatter를 생성후 변환을 하여 `NavigationBar`의 날짜와 `어제`의 박스오피스 순위를 가져오게 진행 하게끔했습니다. 이렇게하면 2개의 프로퍼티 생성을 진행하게 되어 좋지않을꺼라는 생각을 하게 되었으며 다른 방법을 생각 하게 되었습니다.
```swift
private let yesterdayDate: String = {
let yesterday = Date() - (24 * 60 * 60)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: yesterday)
}()
lldb po Date()
2023-08-04 07:01:02 +0000
- timeIntervalSinceReferenceDate : 712825262.9015
```
- `Date()`에는 timeIntervalSinceReferenceDate값을 알게되었고 이값으로 날짜 조정이 가능한점을 알게 되어 간단하게 1개의 프로퍼티 생성으로 날짜 표현을 진행했습니다.
## 조언을 얻고 싶은 부분
### DiffableDataSource 사용시 메모리 누수
| deleteAllItems를 하고 새로 추가하는 경우 | SnapShot을 새롭게 만들어 apply 하는 경우 |
| :-: | :-: |
| <img src="https://cdn.discordapp.com/attachments/1136463230886215751/1136926200422465616/deleteDiffable.gif" width="500"/> | <img src="https://cdn.discordapp.com/attachments/1136463230886215751/1136926200820932619/newDataDiffable.gif" width="500"/> |
- `DailyBoxOffice`의 정보를 가져와서 `diffableDataSource`의 `snapShot`을 생성할 때 `NSDiffableDataSource`에서 `SnapShot`을 생성하는 2가지 방법중 `snapShot`을 새로 만들어서 `diffableDataSource`에 `apply`해주는 방법을 적용했습니다. 기존의 스냅샷을 삭제 및 추가 변경하는 것이 아닌 새롭게 데이터를 받아 `reload` 해줘야했기 때문입니다. 따라서 새롭게 `sanpShot`을 만들어 `section`과 `item`을 추가 하고 `apply`할 때 마다 메모리가 누수되지 않는지 고민하게 되었고 디버깅 툴을 사용해 확인해본 결과 `reload`를 할 때마다 사용하고 있는 메모리가 점점 상승해서 해제되지 않는 현상을 발견하게 되었습니다. 이러한 현상은 기존의 `snapShot`을 받아온뒤 `deleteAllItems`을 하고 다시 `item`을 추가해주는 방식을 사용해도 동일한 현상이 발생하고 있었습니다.
위와 같은 메모리 누수 현상을 해결할 수 있는 방법을 알고 싶습니다.
<참고 자료>
[NSDiffableSnaptShot docs](https://developer.apple.com/documentation/uikit/nsdiffabledatasourcesnapshot)
### Animation도중 Animation이 끼어드는 경우
<img src="https://hackmd.io/_uploads/ry2oBI5oh.gif" width="500"/>
targetDate를 임의로 빈문자열("")로 부여하여 의도적으로 오류를 발생시켜 팝업을 노출시킨 상황에서 RrefreshControl을 사용하여 CollectionView를 Refresh하게 되는 경우 종종 endRefreshing이 호출되었음에도 위의 예시화면처럼 endRefreshing동작을 하지 않는 다는 것을 발견했습니다.
저는 해당 이슈의 원인을 여러가지 애니메이션 메서드가 동시에 호출되어서 endRefreshing 애니메이션 메서드가 호출되어 실행되는 도중에 다른 애니메이션 메서드가 끼어들어서 endRefreshing 동작을 제대로 수행하지 못했다고 생각합니다. 따라서 도중에 여러가지 애니메이션 메서드가 호출되는 것을 방지하기위해 이에따른 예외처리를 구현했습니다. (https://github.com/yagom-academy/ios-box-office/pull/89/commits/96199c0f2bfe3ea6ca6eabb38d3ddcbf0a45d3b0)
하지만 이와 같은 커밋의 수정은 근본적인 해결책이 될 수 없다고 생각합니다. 또한 앞으로 코드를 작성하면서 반드시 겪을 상황이라고 생각합니다.
위와 같은 이슈의 근본적인 원인과 이를 해결할 수 있는 방법에 대해 조언을 구하고 싶습니다.
----
## STEP4 PR
안녕하세요 올라프(@1Consumption)!
박스오피스 STEP4 구현을 진행했습니다.
부족하지만 잘부탁드립니다.
## 고민했던 점
### Image Load시 동적으로 Height 설정하기
- `MovieDetailView`에서 `Image`를 로드할 때 각각의 영화 포스터마다의 `Image Size`가 달랐기 때문에 `Image Size`를 고정으로 부여했을 때 원래 포스터의 크기에 맞는 형상을 출력할 수 없었습니다. 서버로 부터 받아오는 `Response`에서는 `imageURL` 뿐만 아니라 원래 포스터의 `width` 및 `heigh`t의 값을 포함하고 있었고 현재 부여된 `image`의 `width`의 크기에 맞게 비율을 계산한 뒤 해당 비율에 맞게 `height` 값에 대한 `constraint`를 재조정하여 적용했습니다.
그 결과 포스터 `image`를 출력하는데 있어서 원하는 결과를 얻을 수 있었습니다.
----
### Coordinator의 사용
- `ViewController`간의 불필요한 의존성 및 `ViewController`를 좀 더 간결하게 가져가기 위해 화면 전환을 담당하는 `Coordinator` 타입을 설계했습니다.
현재 구현한 앱에서는 2개의 화면을 가지고 있고 2개의 화면에서 공통적으로 의존성을 가지고 있는 `BoxOfficeRepository`, `DaumSearchRepository를` 위해 필요한 `URLSessionProvider`, 화면전환을 위해 필요한 `NavigationController`를 주입받아서 `MainViewController`에 필요한 화면 전환을 담당하도록 했습니다.
각각의 `ViewController` 끼리 의존하는 것은 불필요하고 `ViewController를` 방대하게 만드는 일이기도 하기 때문에 이를 해결해보고자 했습니다.
----
## 다중 API 통신 데이터 처리
```swift
struct DetailMovieDTO {
let imageUrl : String
let movieCode: String
let directors: String
let productYear: String
......
}
private func setUpMovieImageDTOList(_ movieImage: Document) -> DetailMovieDTO {
let movieImageDTO = DetailMovieDTO(imageUrl: movieImage.image_url, movieCode: "", directors: "", productYear: "", ......)
return movieImageDTO
}
private func setUpMovieDetailDTOList(_ movieImage: MovieDetail) -> DetailMovieDTO {
let movieImageDTO = DetailMovieDTO(imageUrl: "",
movieCode: movieImage.movieCode,
directors: movieImage.directors.map {$0.peopleName}.joined(),
productYear: movieImage.productYear,
openDate: movieImage.openDate,
showTime: movieImage.showTime,
audits: movieImage.audits.map {$0.watchGradeName}.joined())
...........
return movieImageDTO
}
```
```swift
private func setUpMovieDetailLabel(_ detailMovieImageViewDTO: [DetailMovieDTO]) {
directorLabel.text = detailMovieImageViewDTO.first?.directors ?? ""
.....
}
func completeFetchDailyBoxOfficeInformation(_ detailMovieImageViewDTO: [DetailMovieDTO]) {
.....
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.mainImageView.image = UIImage(data: data)
self.setUpMovieDetailLabel()
}
}
}
```
```swift
func completeFetchDetailLabel(_ detailMovieImageViewDTO: DetailMovieDTO) {
DispatchQueue.main.async {
self.setUpMovieDetailLabel(movieDetail: detailMovieImageViewDTO)
}
}
```
2개의 API통신을 진행하지만 하나의 completeFetch에서 진행 하고자 했었습니다.
DTO 모델을 기반으로 UI를 업데이트 하고자 했으나 진행 되질않았고 디버깅을 진행할시에는 DTOList에서는 가져오지만 Controller에서는 이미지에대한 데이터값만 가져오고 있었으며 레이블에 추가해야되는 데이터값을 가져오지 못하고있었습니다. 이것에 문제점에서 데이터를 나눠서 업데이트 하지않았기에 생겼다는것을 알게 되었고 이점에서 completeFetch를 나누어서 진행하게 되어 서버와의 통신을 성공하게 되었습니다.
----
# 박스 오피스 </br>
> 영화 순위 및 영화 상세내용 </br>
## 📚 목차</br>
- [팀원소개](#-팀원-소개)
- [파일트리](#-파일트리)
- [타임라인](#-타임라인)
- [실행화면](#-실행화면)
- [트러블 슈팅](#-트러블-슈팅)
- [참고자료](#-참고자료)
## 🧑💻 팀원 소개</br>
| <img src="https://avatars.githubusercontent.com/u/24710439?v=4" width="250" height="250"/> | <img src="https://github.com/hemg2/TIL/assets/101572902/94246a3f-4b06-4b37-abfd-6bab0d345ebb" width="250" height="250"/> |
| :-: | :-: |
| [**Zion**](https://github.com/LeeZion94) | [**Hemg**](https://github.com/hemg2) |
## 🗂️ 파일트리</br>
## ⏰ 타임라인</br>
프로젝트 진행 기간 | 23.07.24.(월) ~ 23.08.11.(금)
| 날짜 | 진행 사항 |
| -------- | -------- |
| 23.07.24.(월 | box_office_sample Model 구현<br> Model Type XCTest 구현|
| 23.07.25.(화) | XCTest code 접근제어자 수정 <br> Equatable -> XCTest에서만 적용 변경 진행|
| 23.07.27.(목) | API, Networking 타입 구현, 네트워크 로직 수정 <br> showNetworkFailAlert 생성|
| 23.07.31.(월) | APIResult -> ResultType 변경 <br> UseCase 생성 DTO 모델 구현 |
| 23.08.01.(화) | Repository 재사용 기능 분리 |
| 23.08.03.(목) | CollectionView, ListCell 생성 <br> ActivityIndicatorView, RefreshContro 생성 <br> |
| 23.08.07.(월) | 타켓 13 -> 14.5 상승 <br> 타켓 상승에 맞는 메서드 수정[(Button: addTarger -> Action), weak,self 참조 진행] |
| 23.08.09.(수) | MovieDetailVC 생성 <br> DaumSearchRepository, UseCase 추가 <br> AppCoordinator 생성 <br> |
| 23.08.10.(목) | setUpRequestURL 프로토콜생성 <br> setUpRequestURLTests 생성 |
| 23.08.11.(금) | README 작성 |
## 📺 실행화면
- BoxOffice 실행 화면 </br>
| 영화 리스트 상세정보 | 날짜 변경 |
| :--------: | :--------: |
|<Img src = "https://cdn.discordapp.com/attachments/1080783877594947597/1139467267990638654/6b6c9458f4ac2428.gif" width="300" height="600">|<Img src = "https://cdn.discordapp.com/attachments/1080783877594947597/1139467268435226694/0b3dc23857a3754a.gif" width="300" height="600"> |
## 🔨 트러블 슈팅
1️⃣ **1** </br>
🔒 **문제점** </br>
ViewController에서 네트워킹을 통해 Data를 Fetch해오는 로직까지 가지고 있었기 때문에 ViewController가 너무 방대해졌고 하는 일이 너무 많아졌습니다. 따라서 이를 해결하기 위해 역할 및 기능을 나눌 필요성을 느꼈습니다.
🔑 **해결방법** </br>
Clean Architecture를 적용하여 기능 및 역할 별로 여러개의 Layer로 나눈 뒤 각각의 의존성을 주입받아 ViewController의 기능 및 역할을 줄이고 ViewController가 방대해지는 문제를 해결할 수 있었습니다.
처음 적용할 때 부터 Clean Architecture의 형태로 적용하려고 했었던 것은 아니었습니다. ViewController를 통해 User가 할 수 있는 동작에 대응하는 UseCase 타입을 따로 두어 ViewController의 방대함을 해결하려 했습니다만, 그렇게 되자 UseCase에서 네트워킹을 통해 data를 Fetch하는 로직을 들고 있게 되기 때문에 data를 Fetch하는 로직 자체를 재활용할 수가 없게 된다는 문제점이 있었습니다. 따라서 이러한 문제를 해결하기 위해 해당 Data Fetch를 재활용하여 사용할 수 있게 Repository 타입을 두어서 재활용 할 수 있도록 했습니다.
최대한 Data를 Fetch하는 로직을 줄이고 재활용했다는 점과 ViewController의 방대함을 해결하고 기능분리를 했다는 점에서 의미가 있었다고 생각합니다.
2️⃣ **2**</br>
🔒 **문제점 2** </br>
```swift
// Background Queue
snapShot.appendSections([.main])
snapShot.appendItems(movieInformationDTOList)
DispatchQueue.main.async {
self.diffableDataSource?.apply(snapShot)
}
```
ModernCollectionView 중 UICollectionViewDiffableDataSource의 사용에서 바뀌어진 SnapShot을 Apply하는 코드를 MainThread에서 실행될 수 있도록 불필요하게 옮겨서 사용했습니다.
🔑 **해결방법** </br>
DiffableDataSource를 Apply하는 코드는 의도적으로 MainThread로 옮겨서 실행될 필요가 없습니다.
현재 data를 갱신하는 코드는 Background Queue에서 진행이되고 있지만 현재의 UI State와 바뀌어진 UI State에서의 SnapShot의 Diff 계산이 끝났다면 자체적으로 내부에서 MainThread 옮겨서 UI 갱신을 해주기 때문에 개발자가 따로 해당 코드를 MainThread에서 옮겨서 진행할 필요가 없습니다.
3️⃣ **3**</br>
🔒 **문제점 3** </br>
StackView 내부에 여러개의 StackView를 넣었을 때 각각의 StackView에 내부 컴포넌트들의 IntrisicContentSize만큼 크기를 잡지 못하고 오류가 발생했습니다.
🔑 **해결방법** </br>
이를 해결하기 위해 2가지 방법을 사용했습니다.
## Alignment.Fill option이란?
> A layout where the stack view resizes its arranged views so thye fill the available space perpendicular to the stack view’s axis
>
- Alignment.Fill option은 내부 컴포넌트의 intrinsicContentSize 만큼 StackView의 사이즈 자체를 맞춰주는(resize) option이다.
- 즉 따로 StackView size에 해당하는 조건은 부여하지 않아도 내부 컴포넌트의 intrinsicContentSize를 기준값으로 잡아 StackView의 size를 맞춰준다는 의미의 option이다.
- 해당 옵션을 부여하면 더이상 애매한 StackView width나 height를 가지지 않는다. 내부 컴포넌트의 size를 통해 현재 size를 유추할 수 있기 때문!
## ContentHugging Priority란?
- 현재 자신의 크기에 대해 컨텐츠가 늘어나지 않고 유지하려는 우선순위이다.
- 쉽게 말해서 axis(horizontal, vertical)에 StackView 내부에 존재하는 컴포넌트가 Content Hugging Priority가 더 낮은 컴포넌트가 존재한다면 상위 StackView의 크기가 커졌을 때 Content Hugging Priority가 더 낮은 컴포넌트의 크기가 덩달아 늘어난다는 의미이다. 왜? 우선순위가 더 낮으니까!
- Horizontal, Vertical의 기본 우선순위는 Low: 250, High: 750으로 부여되어 있으며 해당 값보다 기준으로 더 낮다면 해당 컴포넌트가 상위 StackView에 의해 크기가 늘어나게된다.
## StackView 내부에서 기준이 되는 값을 지정해주기
- StackView가 명확한 width, height를 갖지 못하는 경우가 반드시 존재한다 이는 StackView 내부에 StackView가 존재할 수도 있고 생각보다 복잡하게 활용될 수 있기 때문이다.
- 따라 위와 같은 이슈가 발생할 때는 StackView가 크기를 유추할 수 있게 옵션을 부여하는 것이 중요하다! → 기준값 설정 (Alignment.Fill option 사용)
- 또한 상위 StackView의 크기에 따라 내부의 StackView나 다른 컴포넌트들의 크기가 늘어나야할 경우 여기서도 어떠한 컴포넌트가 늘어나야할지 정해져 있지 않다면 내부 컴포넌트들의 크기를 StackView가 유추할 수 없기에 ContentHuggingPriority를 조절해야한다. 이렇게 조절함으로써 어떠한 컴포넌트가 늘어나야할지 지정해줄 수 있으므로 해당 컴포넌트의 크기가 증가하여 그 크기를 기준으로 내부 Size를 잡아줄 수 있다.
## 결론
- StackView 사용시 내부 크기가 애매하게 지정되어잇따면 ContentHugging과 Alignment.Fill option으로 내부 크기에 대한 기준을 잡아줄 수 있음을 기억하자!
## 📑 참고자료
- [📃 URLSession](https://developer.apple.com/documentation/foundation/urlsession)</br>
- [📃 Fetching Website Data into Memory](https://developer.apple.com/documentation/foundation/url_loading_system/fetching_website_data_into_memory)</br>
- [📃 Encoding and Decoding Custom Types](https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types)</br>
- [📃 Protocols](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/protocols/)</br>
- [📃 UIAlertController](https://developer.apple.com/documentation/uikit/uialertcontroller)</br>
- [📃 UICollectionView](https://developer.apple.com/documentation/uikit/uicollectionview)</br>
- [🎥 WWDC - Modern cell configuration](https://developer.apple.com/videos/play/wwdc2020/10027/)</br>
- [🎥 WWDC - Lists in UICollectionView](https://developer.apple.com/videos/play/wwdc2020/10026)</br>
- [📃 Implementing Modern Collection Views](https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views)</br>
- [📃 Entering data](https://developer.apple.com/design/human-interface-guidelines/entering-data)</br>